// This source code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © AlphaTraderRyan
// Portions adapted from open-source work, used under the Mozilla Public License 2.0:
//   - Higher timeframe candle overlay (candles, trace lines, HTF labels, countdown, custom daily open)
//     adapted from "ICT HTF Candles [Source Code]" by fadizeidan. © fadizeidan
//   - SMT asset selection (correlation preset, manual assets, Asset 3 invert and reordering)
//     adapted from "cephxs + fadi / HTF PSP" by cephxs and fstarcapital. © cephxs & © fstarcapital
//@version=6
// v4.57
// v4.57: OPEN-SOURCE RELEASE (2026-09-29). MPL 2.0 licence header and credit block added above
//   //@version, plus two inline credit comments (trace inputs, asset selection). Supersedes the
//   v4.54 name scrub for these two credits: an open-source MPL publish must keep the upstream
//   copyright notices. Code unchanged from v4.56 (code-only diff verified empty). NEEDS-COMPILE-TEST.
// v4.56: RENAMED (2026-09-28). Title 'Maulers HTF Candles + SMT/Multi-CISD' -> 'Maulers Raid to
//   Delivery', shorttitle 'MM HTF' -> 'MM RTD'. TradingView hid the 2026-09-28 free publish under
//   the originality/usefulness rule. The old title led with the same phrase as the open-source
//   script this account was actioned over in July, so a moderator reads the collision before the
//   description. New title names the sequence the tool tracks instead of one display component.
//   Code unchanged: this is the declaration line and this comment only.
// v4.55: RE10143 buffer fix (see the block under indicator()). NEEDS-COMPILE-TEST.
// v4.55: tooltip text corrected to match the code (2026-09-28, pre-publish): Fill Type default,
//   Thicken Stacked Pools default, CISD table status words, Column 3 default. Text only.
// v4.54: NAME SCRUB before the free publish (Ryan, 25/9/2026: remove every third-party name).
//   The Opposing setups tooltip and the v4.53 research note now cite "the published Fractal
//   Model material" instead of a person.
// v4.54: HTF FVG "Include chart timeframe" toggle (Ryan, 25/9/2026). A 1H slot on a 1H chart
//   was hiding by design (strictly-higher gate) and read as a bug. Default OFF = old behaviour;
//   ON lets a slot equal to the chart TF draw. Slot tooltips now say why a slot hides.
// v4.54: HTF FVG Fill Type, Wick (default, unchanged) or Close (the gap's own TF candle closing beyond the far edge).
// v4.54: NEARBY LIQUIDITY. Major SSL/BSL was skipping the nearest pool when it sat on PDH/PDL etc.
//   (levels this file never draws) and jumping to one far away. Key-level skip and the v4.16
//   fall-through REMOVED entirely (Ryan: no need for that setting). Pools on key levels draw and are tagged.
// v4.54: 'Minors per side' (default 3) - several minors between price and the major, nearest first.
// v4.54: 'Tag key levels' (default ON) - a pool on PDH/PDL/PWH/PWL/PMH/PML says so in its label.
// v4.54: CISD table 'Daily Bias' top row (default ON) - yesterday's close vs the day before's PDH/PDL.
// v4.54: TOKEN BUDGET. v4.54 compiled at 101,482 tokens (limit 100,256). Removed the two developer
//   debug layers - 'CISD Debug markers' and CISD FVG 'Debug: show every candidate' (pool, marker fn,
//   readout table) - neither touches the model. Full debug code survives in the v4.53 backup.
// v4.54: f_cisdEngine tuple 32 -> 22. The ten dropped fields (run opens/times, capture levels/times,
//   leg-origin times) were read ONLY by the CISD debug markers. Still computed inside the engine;
//   just no longer shipped out of request.security. Same removal at all three sites, order kept.
//   f_pcLeft deleted - its only caller was the CISD FVG debug marker.
// v4.54: PUBLIC DEFAULTS = Ryan's own chart setup (his call, 25/9/2026): 1H/4H/1D lanes x7, black
//   line work for a light chart, HTF FVG 1H + 1D on Close fill, LRLR, all four liquidity sides dotted
//   with 5 minors, EQH/EQL, VI, C1/C2/C3 tags, stop + size, CISD table with 1D column.
// v4.53: OPPOSING SETUP FLAG. Ryan found this one on 27/8/2026 with bar replay, and the
//   engine was right the whole way through - which is exactly why the flag is worth having.
//   A 15m bear C2 confirmed its 1m CISD. That setup's own C3 then swept the prior candle's
//   LOW and closed back inside, which makes it a bull C2 by the same test that made the bear
//   one. So one candle is the short's C3 and the long's C2 at the same time. The short never
//   failed: its C3 went DOWN, the direction it wanted, so the C2 high was never taken - and
//   past C3 a confirmed setup cannot fail at all (Ryan's law v4.25). Both directions
//   confirmed, both correct, both drawn.
//   WHAT WAS MISSING. If you are IN that short, a long confirming at your objective is the
//   most important thing on the screen, and nothing said so. Bull and bear are fully
//   independent state in the engine and neither knows the other exists. This adds the one
//   sentence, and only that.
//   IT CHANGES NOTHING. Same law as CISD FVG: reads the confirmed pool AFTER the pruning has
//   run, drops ONE tag, fires ONE alert, stops. No gate, no signal, no state anything else
//   consumes. Turn it off and the model behaves identically. If a future edit makes this
//   block influence a setup, that edit is wrong.
//   THREE CALLS, ALL RYAN'S. Same fractal pair only - a cross-selector conflict (a 1m bull
//   against a live 15m bear) is a different and much noisier signal. Confirmed only - a hunt
//   is not a signal, and one of these lived for a single 5m bar during the same session.
//   Tags the NEW setup only - a setup you may be holding must not change appearance because
//   something else printed.
//   Ryan's law (v4.53): the opposing C2 has to BE the older setup's own C3 or C4 candle.
//   Nothing after C4 is a conflict - that setup's sequence has played out and the next C2 is
//   simply the next setup. This is the SAME C3 -> C4 lifetime the CISD table row runs on, so
//   a tag on the chart and the Seq cell can never tell different stories.
//   OFF BY DEFAULT, like every optional layer in this file.
//   THREE MODES, NOT A BOOLEAN. Off / Flag / Void, because the three are mutually exclusive
//   and a picker says so structurally where two checkboxes would let you set a contradiction.
//   FLAG changes nothing. VOID removes the opposing setup outright - line, tag and ladder -
//   and is the ONE part of this block that touches the model, which is why it is opt-in and
//   labelled the same way Mauler Mode is: an in-house variation, not the standard model.
//   WHAT THE RESEARCH SAID (27/8/2026, before Void was written). The published Fractal Model
//   material does NOT void these. It names "Opposing Setup Formation" as one of two tradable
//   invalidation scenarios and has you wait for the opposing structure to form before
//   entering - it is traded, not discarded. There is no published C5 rule and no published
//   opposite-direction void anywhere in that material. Other public builds of the model carry
//   a three-state label instead - valid, failed when price returns to the origin without a
//   HTF swing point, and a consolidation warning one HTF candle later - which is a WARNING,
//   not a void, and is much closer to what Flag already does here. So Void ships as a
//   Maulers variation with that said plainly in its tooltip, and Flag stays the recommended setting.
//   VOID CANNOT SUPPRESS UPSTREAM. The window test needs the done pool AFTER HuntCISD's
//   pruning chain, and the setup has already fired its CONFIRMED alert by then. Rather than
//   leave that confirm dangling with nothing on the chart, Void sends its own VOIDED message.
// v4.52: MICROS WHEN A MINI IS TOO MUCH RISK. Ported from MM IFVG v6.42 the same
//   session Ryan asked for it there - shared engine, so it moves as a port and not
//   as a second implementation.
//   THE SAME BUG WAS HERE. `math.max(1, math.floor(pl_risk / perR))`: when one
//   contract costs more than the whole budget, floor() returns 0 and the max(1, ...)
//   floor overrode it back to 1. So the label quoted a position past the member's own
//   stated limit and said nothing about it. Silently, and always toward more risk.
//   THE FIX IS THE FEATURE. If one MINI does not fit, size in MICROS. "Micros per
//   Mini" is an input, default 10 - right for NQ/ES/YM/GC/CL, adjustable for anything
//   that is not a clean tenth, and 1 turns it off. An input rather than a constant
//   because the ratio is NOT universal (RTY's micro is M2K, Bitcoin's is 1/50).
//   NO SYMBOL NAME PRINTED, same reasoning as IFVG: "MNQ" from "NQ" works, "M2K"
//   from "RTY" does not, and naming the wrong contract is worse than saying "micros".
//   LABEL STAYS COMPACT. This one tucks at the right end of the stop line at Tiny by
//   default with up to three slots of setups around it, so it reads "3 minis · $498"
//   and "7 micros · $472" rather than IFVG's longer "3 mini contracts = $498". Same
//   vocabulary, shorter form, and it fixes an old ambiguity - "3c" never said which
//   contract it meant.
//   TWO TOOLTIPS CORRECTED, because they documented the behaviour that just changed:
//   pl_lbl still quoted the old "3c · $498" format, and pl_risk still promised a
//   "floor of one" that no longer exists. A stale tooltip is worse than no tooltip.
//   Ryan's ruling carried over: when even ONE micro exceeds the budget there is no
//   flag. It quotes the micro and stays quiet. Deliberate, not an oversight.
// v4.51
// v4.51: SMT LABEL ANCHOR - LEFT / CENTER / RIGHT. The correlate tag on the mini-candle
//   SMT mark was pinned to the midpoint of the line with no way to move it. Now it picks
//   an end. Named Left/Center/Right rather than Left/Middle/Right to match the anchor
//   input MM IFVG's LRLR already ships, so the two tools use one vocabulary for one idea.
//   TWO THINGS MOVE TOGETHER, and the second is the part that matters. The X anchor is
//   obvious. The Y anchor now follows it: Left takes smY1, Right takes smY2, Center keeps
//   the max/min extreme it has always used. An SMT line is DIAGONAL - the two ends sit at
//   different prices - so anchoring a left-hand label at the line's overall extreme would
//   float it away from the end it is supposed to be labelling, and the steeper the
//   divergence the further it drifts. Center is unchanged, so nobody's existing chart moves.
//   The vertical STYLE is deliberately untouched: above on a bear mark, below on a bull.
//   That offset is what keeps the tag off the line it describes, and switching to a
//   side-anchored style at the ends would drop the text onto the line to gain nothing.
// v4.50
// v4.50: A FAILED C3 MARKS F3. Ryan's ask, confirmed against a chart where a bearish 1H
//   C3 held and a bullish 15m C3 did not: "red arrow on the 15m c3 that failed (hit c2
//   extreme)."
//   FAILED MEANS THE C2 EXTREME WAS TAKEN. Bull setup: C3's low goes below C2's low. Bear
//   setup: C3's high goes above C2's high. That is not a new definition invented for a
//   label - it is the same event InvalidateConfirmed already treats as the end of a setup
//   and reports as FC2, and the comparisons here use the same STRICT operators the engine
//   does (low < c2 / high > c2), so the tag and the FC2 alert can never disagree about
//   whether a setup broke.
//   No new detection and no engine coupling. The test is two values already sitting in
//   the lane's own price arrays, evaluated where the tag is drawn.
//   It updates live: while C3 is still developing the tag reads C3 and flips to F3 the
//   moment the wick takes the level. Once C3 closes the stored values stop moving, so a
//   failure is permanent and a survivor stays a survivor.
//   F3 gets its own colour on the same settings row, defaulting red. A failure marker in
//   the same gold as C1 and C2 would be a state change you have to read rather than see.
//   C1 and C2 keep their tags either way - what happened to them is still true.
// v4.49
// v4.49: C1/C2/C3 TAGS FOLLOW THE SETUP'S DIRECTION. v4.48 put all three under the low
//   wick on every setup, which was my call and it was wrong. Ryan, reading a bearish 15m
//   lane with the tags sitting under the lows: "the 15m is bearish so those labels should
//   be on the highs of the candle not the low."
//   BULL SETUP = C2 swept C1's LOW  -> tags UNDER the low wick.
//   BEAR SETUP = C2 swept C1's HIGH -> tags ABOVE the high wick.
//   Setup direction, not candle colour. A bear C2 can print green and often does; what
//   makes it bearish is which side it raided. Going by colour would scatter one triple
//   across both sides of the lane and read as noise.
//   The pool did not carry direction, so Slot gained `swBull` as a fourth parallel column
//   beside swC2/swC1/swLvl. Full parity discipline: declared, initialised, cleared, pushed
//   and removed at every one of the four sites the other three are touched at. The house
//   rule is EVERY column or the rows desync, and a bool that silently fell out of step
//   would put a triple on the wrong side of the lane rather than crash, which is worse.
//   Worth recording why this looked like something else first: with the tags under a
//   bearish lane, the C3 tag lands beneath a candle that ran the other way, and it reads
//   as a setup that broke. It was placement, not a failed C3.
// v4.48
// v4.48: C1 / C2 / C3 MARKED ON THE MINI CANDLES. Ryan's ask, optional, off by default.
//   A small tag under each candle's low wick naming its place in the fractal. This is the
//   one label that makes the model readable at a glance, and it is worth more to a member
//   still learning where C2 ends than it is to either of us.
//   NO NEW DETECTION. The slot already stores the C1 and C2 candle IDs in s.swC1 / s.swC2,
//   and f_idPos maps an ID back to its lane position, so C3 is the next position along.
//   This reads a pool that already exists and adds no state to maintain.
//   ONE TRIPLE PER SLOT, replaced when a new C2 closes. That is not a new decision - it
//   falls straight out of v4.20, which made the sweep pool newest-only because every
//   historical sweep staying drawn buried the one that mattered.
//   THE COUPLING THIS BREAKS: recording swC1/swC2 used to be gated on in_sw_on, so with
//   HTF Sweeps off the IDs were never stored and these labels would have had nothing to
//   read. Recording is now ungated and in_sw_on governs only whether the sweep LINE draws.
//   That is the same split the file already made for the C2 alert three lines above, under
//   the comment "whether a C2 closed is not a drawing question". Same principle, and the
//   two features stop being wired to each other. in_sw_on gained a tooltip saying exactly
//   what it does and does not turn off, per Ryan.
//   Also added there: an n >= 3 guard on the push. s.id.get(n - 3) on a two-candle lane
//   would index -1. It has been reachable in principle since v4.20 and never fired, which
//   is the kind of luck worth spending a condition to stop relying on.
//   CHOICES MADE, each a one-liner to reverse: tags sit UNDER the low wick on all three
//   regardless of setup direction, which is how Ryan described it. C3 marks while it is
//   still the developing candle, because that is the window you are actually trading. One
//   colour and one size for all three, matching every other row in the Labels group.
// v4.47
// v4.47: SD LEVEL HITS GO INSTANT. Ryan, asked whether HTF and IFVG should be reconciled
//   on when an SD tag reports: "Instant for sure no doubt."
//   Both files already DETECTED on the wick - `low <= p and high >= p` here, `high >= iS1`
//   over there - and neither changed. What differed was WHEN each asked the question. This
//   one asked at the close of the bar; MM IFVG asks the moment price gets there. Two
//   unreconciled decisions, not a bug, and now settled in IFVG's favour.
//   Two edits, and it needed both. f_sdHitScan drops barstate.isconfirmed from its gate,
//   AND f_alert's confirmed-bar gate becomes a parameter that this one call site passes
//   false to. Either alone does nothing: sd.hits latches the instant the wick lands, so a
//   transport still waiting for the close would arrive to find the latch set and the
//   `if not sd.hits.get(i)` guard would swallow the alert permanently.
//   Nothing else moves. The other 21 call sites pass true and fire exactly as before, and
//   the anti-tick-spam reasoning that put the gate there in v4.x still governs them.
//   What makes it safe for THIS event specifically: the message is built from the LEVEL
//   (line.get_y1) and the multiple, never from live price, so every intrabar recalculation
//   produces byte-identical text and the varip dedupe absorbs it. A deterministic message
//   is the precondition. Do not lift this pattern to an event whose text moves with price.
//   f_alert's signature now matches MM IFVG v6.32's hand for hand. One transport, two files.
//   NOT changed: the retest alert still waits for the close. That one IS an entry trigger
//   and it is a separate call.
// v4.46
// v4.46: KILLZONE FILTER, PORTED FROM MM IFVG v6.0. Same three windows, same defaults,
//   same ET DST-aware session reads, same inKz expression. This file had NO time gating
//   of any kind before now - a 3AM CISD reached you exactly as loudly as a 9:45 one, and
//   overnight noise is the fastest way to make a member mute an alert and then cancel.
//   IT GATES ALERTS, NOTHING ELSE. One condition, added to one place: f_alert. That
//   single edit covers all 24 alert sites in the file at once, and because it lives in
//   the transport rather than the engine it cannot touch a setup. Outside your windows
//   the chart behaves exactly as it always has - setups still arm, still confirm, still
//   draw, still project, still invalidate on their own rules. They just do it quietly.
//   That is deliberately NOT what MM IFVG does. There the killzone CONSUMES the gap
//   (f_wDel) so the setup never exists at all. Same feature name, two different jobs,
//   because the two tools disagree about what a setup outside a window is: IFVG treats
//   it as a trade you would not take, this treats it as context you still want drawn.
//   Ryan's stop rule from v4.45 applies just as hard here - nothing this file adds gets
//   to change what shows and what fails.
//   Extending the filter to DRAWING as well is a separate decision and a much bigger
//   surface (every draw site, not one gate). Not built, deliberately, until asked.
// v4.45
// v4.45: TRADE PLAN - STOP LEVEL + POSITION SIZE. A READOUT LAYER, AND ONLY THAT.
//   Ryan's law, taken before a line of this was written: "nothing should fail when stop is
//   hit." So this layer reads from the engine and never writes to it. Price trading through
//   a drawn stop changes NOTHING - not setup state, not the pools, not a colour, not an
//   alert, not a deletion. The stop line lives exactly as long as the CISD line it belongs
//   to and leaves with it.
//   What can still end a confirmed setup is unchanged and unchanged deliberately:
//     · InvalidateConfirmed - the C2 extreme taken WHILE STILL INSIDE C3 (the FC2 alert).
//       After C3 closes, inC3 goes false and nothing can fail that setup. Untouched.
//     · CapSetups - the budget cull, oldest first, stacked members exempt. Untouched.
//   Worth naming the difference from MM IFVG, because it is easy to misread: the stop does
//   not kill setups there either. What kills an IFVG zone is a confirmed close through its
//   far side, and f_planKill runs as a CONSEQUENCE of that. Its SL just happens to sit on
//   the zone edge plus a buffer, so the two land on top of each other and look like one
//   event. Here the geometry does not coincide, because HTF lets setups outlive their
//   invalidation window on purpose - so there is nothing to decouple, only something to
//   not build.
//   ENTRY = THE CONFIRMING CLOSE. Ryan: "entry needs to stay at the cisd confirmation.
//   That is the usual entry for this model - the other entry is when we retest the cisd but
//   that isnt always." So the retest entry is deliberately not modelled. The engine now
//   carries bullConfClose/bearConfClose, captured at the same instant as the existing
//   ConfTime fields, in the same function, riding the same tuple across the same
//   request.security boundary. No new security call, no new non-repaint idiom to reason
//   about - it inherits ConfTime's behaviour exactly, and ConfTime is already trusted
//   enough to position the confirmed line with.
//   STOP PLACEMENT - three, Ryan's list of what people actually use on this model:
//     · C2 Extreme  - the same level FC2 watches, so the drawn stop and the engine's own
//                     definition of wrong agree by construction.
//     · CISD Level  - the level itself, no buffer.
//     · CISD + Buffer - the level plus SL Buffer ticks, beyond it in the losing direction.
//   The buffer applies to the third mode only, per his spec. One-line change if he wants
//   it on the C2 mode too.
//   SIZE: perR = |confirming close - stop| x pointvalue, qty = floor(risk budget / perR)
//   with a floor of 1, label reads "3c · $498". Ported from MM IFVG's f_addIFVG math
//   verbatim. Optional, per his call, on its own toggle.
//   WHERE IT LIVES: the stop line and its label hang off SdLadder rather than getting
//   their own columns in CisdBook. Deliberate. The ladder's lifetime is ALREADY exactly
//   the setup's lifetime, and every removal path in the file already calls f_clearLadder -
//   InvalidateConfirmed and CapSetups both. Adding parallel columns to the done pool would
//   have meant editing every one of those removal sites and betting on never missing one,
//   which is the exact failure the "EVERY column, or the rows desync" rule exists for.
//   The ladder object is built and pushed whether or not projections are on, so the stop
//   does not inherit the SD toggle.
//   Whole group defaults OFF, per v4.5 precedent.
// v4.44
// v4.44: HYGIENE PASS. Three cuts, no behaviour change on the chart.
//   1) SD-HIT DEDUPE MOVED INSIDE THE LADDER. f_sdHitScan used to dedupe by scanning a
//      global sdHitLns registry with .indexof() - a linear walk of up to 300 line refs,
//      run once per ladder level, per setup, per book, on every confirmed bar. That was
//      the hottest loop in the file and it grew with the pool. SdLadder now carries an
//      `hits` bool array index-parallel with `lines`, so the test is one .get(). The
//      registry and its 300/100 drain are gone.
//      It also closes a latent hazard: sdHitLns held refs to lines that f_clearLadder had
//      already deleted, and read prices off them. A latch that lives INSIDE the ladder
//      dies with the ladder, so a culled setup can no longer leave a stale ref behind.
//      Same pattern MM IFVG has used since its v6.0 (iH1-iH4). Both files, one mechanism.
//   2) pcDirs DELETED. The CISD FVG layer pushed a direction column that nothing ever
//      read. It was redundant twice over: slot already encodes direction (bSlot = who*2,
//      sSlot = who*2+1, so even = bull, odd = bear) and pcOwner already stores slot. The
//      `dir` parameter came off f_pcAdd with it - it fed nothing else.
//   3) f_lqSize() -> f_szName(string). Same output, but MM IFVG calls the identical
//      mapper f_szName and passes the input in. One name, one shape, one place to fix.
// v4.43
// v4.43: RENAMED TO "CISD FVG". Ryan's call, taken before it reaches a guide or the site and
//   before a week of live use makes the old label muscle memory. Post-CISD FVG was accurate
//   but clinical; CISD FVG is how the thing is actually said out loud, and it reads as "the
//   FVG belonging to this CISD" - which is exactly the relationship the layer draws.
//   User-facing only: the input group, the toggle label, the debug table header and the
//   section banner. The internal pc* prefix stays as-is - renaming a working data model for
//   a label change is churn with a compile risk and no benefit to anyone.
// v4.42
// v4.42: POST-CISD FVGs — BOUNDED BY THE CISD, TAG ON THE RIGHT. v4.41 landed the gap; these
//   are Ryan's two notes on it.
//   Ryan's law: "FVG only goes as far as CISD." The box used to run to the current bar, so a
//   gap kept extending long after the level that owned it had stopped. Its right edge is now
//   the owning CISD LINE's endpoint, which means the box inherits the setup's own lifespan
//   for free - it grows while the setup is live and stops where the setup stops.
//   That forced a coordinate change: boxes are xloc.bar_time now, not bar_index, because the
//   CISD lines are drawn in bar_time and the two can only be held together if they speak the
//   same system. The left anchor comes from the gap's own c1 (c3's open, two LTF bars back)
//   rather than a bar-index offset, which is also more honest about where the gap formed.
//   The maintenance pass is gone with it - the right edge is set in f_pcRight, called from
//   f_pcGen, the one place that already knows which line owns the slot.
//   Second note: the timeframe tag moves to the RIGHT edge of the box. It reads as the label
//   of a zone you are looking back at, and it keeps the text off the candles that built the
//   gap.
// v4.41
// v4.41: POST-CISD FVGs — THE SETUP CLAIMS THE GAP. v4.40 fixed the direction bug and the
//   verdict moved to "no live bull setup" on the same gap, while the readout showed
//   BULL lvl=29630.75. Both true, and together they name the real shape of this: the gap is
//   detected at 10:25 and the bull setup does not reach the confirmed pool until after that.
//   A gap is found 2 bars after its own c2, and the setup born from that SAME displacement
//   candle arrives on its own read - so the gap routinely exists BEFORE its owner. Every
//   version so far required the owner to already be present. That is a RACE, not an offset,
//   and moving the window start was never going to fix it.
//   Fix: a rolling buffer records every gap the instant it is seen, with no setup required.
//   On confirm the setup walks back through it and CLAIMS the first gap on the correct side
//   of its level, drawn at the anchor where it actually formed. Gaps arriving later still
//   take the live path, so both orderings work. The window is now simply the level's own
//   origin - v4.37's "3 bars back" constant existed only to chase this lag and is gone, so
//   there is no tuned value left in the layer to drift.
//   Ryan's law, and a spec correction on my part: "we want this FVG to stick with the setup
//   so once it prints it stays until entire setup disappears." The fill test is REMOVED. It
//   was inherited from the HTF FVG boxes and is wrong here - a gap getting tapped and
//   HOLDING is the most informative thing this layer can show, and deleting the box on that
//   tap erased the exact case worth seeing. These boxes die one way: their setup leaves the
//   confirmed pool.
// v4.40
// v4.40: POST-CISD FVGs — THE ACTUAL BUG. Found by measurement, not by reasoning: with the
//   debug readable, Ryan's gap tagged "S0 BISI wrong direction, top 29687.00 bot 29649.00"
//   while the readout said dir=bull. Those only disagree because the readout shows the LAST
//   bar and the tag shows 10:25 — and at 10:25 the newest entry in the confirmed pool was
//   still the BEAR setup from the 09:30-10:15 decline. A textbook BISI was tested against a
//   bear setup and thrown out.
//   Root cause: the layer read "the live setup" as the NEWEST entry in cisd_done_lines,
//   either direction. That is a broken proxy. The engine tracks bull and bear independently
//   (cisd_bull_line / cisd_bear_line) and anything reading it must do the same, or one
//   direction confirming blinds the other for as long as it stays newest.
//   Fix: SIX slots instead of three — selector x direction, slot = who * 2 + (bull ? 0 : 1).
//   Each slot carries its own owning setup, scan window and one-shot latch. A BISI is always
//   judged against the newest BULL setup and a SIBI against the newest BEAR one, so the
//   "wrong direction" verdict cannot exist any more; it has been removed from the debug.
//   Note for the record: v4.37's window backup and v4.36's side test were both aimed at
//   symptoms of THIS, diagnosed by reasoning rather than measurement. Both are kept because
//   both are independently correct, but neither was ever the fault.
// v4.39
// v4.39: DEBUG MODE, MADE READABLE. v4.38's debug produced its first real finding — the
//   detector DOES see Ryan's gap, exact geometry, top 29687.00 bot 29649.00 — which retires
//   both earlier theories: nothing was wrong with detection, the window, or the side test.
//   But the readout hid the half that mattered. Two faults, both now fixed:
//   1. Candidate labels from different selectors stacked. Selectors set to the SAME LTF
//      produce identical candidates at identical coordinates, so the last one drawn (S2)
//      covered S1 and S0 — and the verdict needed was the LIVE selector's, underneath.
//      Labels now stagger by a fraction of the gap's own height, so they separate at any
//      scale, and a merely-switched-off selector greys out instead of competing for the eye.
//   2. The status readout was a label anchored at (bar_index, high) on the last bar, which
//      lands off the right edge or behind the price scale as often as not. It is a table
//      now, pinned bottom-left, where it cannot be scrolled away from.
//   Root cause of the stacking, worth knowing beyond the debug: f_selLtf falls through to
//   timeframe.period for ANY unmatched value, 'Off' included. So an Off selector still
//   resolves its LTF to the chart TF and its security read still returns real gaps. The CISD
//   engine never notices because cisd_en_* gates it separately - but anything that reads the
//   resolved LTF without checking the enable flag is reading a live timeframe off a dead
//   selector. Ryan runs CISD 1 = Chart with 2 and 3 Off, so on a 5m chart all three resolved
//   to "5" and the two dead ones drew phantom candidates over the only live one. Debug now
//   marks enabled selectors only.
//   The lesson is logged: the first debug pass answered the question and then obscured the
//   answer. An instrument that can hide its own output is not yet an instrument.
// v4.38
// v4.38: POST-CISD FVGs — DEBUG MODE. v4.37's window fix did not land either, and that is
//   two theories in a row spent on a symptom that has never actually been measured. Stop
//   reasoning about it: this adds a Debug toggle that makes the engine show its work.
//   With it on, EVERY 3-candle gap the scanner produces gets a dashed outline and a tag, in
//   both directions and regardless of every gate, and the tag names the FIRST gate that
//   rejected it - wrong direction, too early (with the miss quantified in bars), below/above
//   the level, already latched, selector off. A corner readout carries each selector's live
//   direction, level, window-open time, latch state and a running count of gaps seen.
//   The count is the important one and it is written EVERY bar, not only on a detection:
//   "the detector never fired at all" is a real possible answer and it is invisible in a
//   readout that only updates when something is found.
//   Debug drawings live in their own pool - they exist to show candidates the live layer
//   REJECTED, so they must never share it.
// v4.37
// v4.37: POST-CISD FVGs — THE WINDOW WAS OPENING TOO LATE. v4.36 marked one gap per setup
//   but kept picking the wrong one: on Ryan's 7 Aug NQ replay it drew at 29,740 when the gap
//   he wanted sat at 29,650, directly on the 29,630.75 level.
//   Diagnosis, from the tool's own behaviour rather than from the chart: the one-shot latch
//   means that once a box draws for a setup no later gap can draw. A box DID appear at
//   29,740 on that selector, so the low gap was never drawn at all - rejected before
//   f_pcAdd. Since the same selector demonstrably drew a box moments later, the plumbing,
//   the direction match and the side test were all working, which leaves exactly one gate:
//   the scan window's start.
//   Root cause: the gap this layer exists to mark is normally created BY the confirming
//   candle. That candle displaces through the level, so it IS c2 of the pattern - c1 sits
//   one bar before the confirm, c3 one bar after. Anchoring the window to the observed
//   confirm bar puts the most important gap just behind the line, and the confirm read's own
//   lag compounds it. The scan then walks up the leg and takes the next gap instead.
//   Fix: open the window 3 LTF bars before the observed confirm - the pattern's own span
//   plus the lag - floored at the CISD line's x1 so it can never reach back past the level's
//   origin and adopt a gap from the sweep leg.
// v4.36
// v4.36: POST-CISD FVGs — THE ONE ON THE LEVEL, NOTHING ELSE. v4.35 drew every
//   direction-matched gap that opened after the confirm, which on a long leg meant three or
//   four boxes stacked up the run. Ryan, looking at it live: "not getting the BISI we truly
//   want. We also don't really want the ones marked, those are higher up. We are more
//   focused on the FVG that's above or below our CISD. Nothing else."
//   Ryan's law: the gap that matters is the one sitting ON the level — that is the one that
//   explains a missed retest. A gap 150 points up the leg explains nothing about the CISD;
//   it is just a gap, and MM Gaps / FVG Finder already own those.
//   So: ONE box per setup. The first direction-matched gap after the confirm, which is the
//   adjacent one because the leg starts at the level, and it must reach past the level in
//   the setup's direction. Once it draws, that setup's scan is CLOSED — a later gap cannot
//   replace it, cannot stack on it, and a fill does not re-arm it.
//   The side test reads the FAR edge on purpose, so a gap that STRADDLES the level survives:
//   a CISD sitting inside its own gap is the strongest version of this pattern, not a
//   disqualifying one. Adjacency comes from taking the first, never from a distance cap —
//   no tuned constant means nothing to drift across symbols or timeframes.
//   The per-selector eviction cap came out with this; one box per selector cannot overflow.
// v4.35
// v4.35: POST-CISD FVGs — A MARKING LAYER, NOTHING MORE. Ryan's spec, stated twice and
//   worth writing down: this changes NOTHING about the model. Not the setup logic, not
//   entries, not targets, not a single existing signal. It scans for FVGs after a CISD
//   confirms and draws them. That is the whole feature.
//   Ryan's law: the layer is OPTIONAL and OFF by default, and the trader owns its colours
//   and opacity — one input.color per direction, whose picker carries the opacity slider,
//   exactly the way the HTF FVG slots already do it.
//   WHY IT IS WORTH SEEING. A confirmed CISD does not always get retested, and the gap
//   sitting above it is usually the reason — price does not come back for a level while
//   unfilled imbalance is holding it up. Marking those gaps makes the no-retest case
//   visible while it is happening instead of obvious afterwards. It is information the
//   trader reads. The tool draws no conclusion from it.
//   Scans the CISD's OWN timeframe (the pair LTF), Ryan's call — the gap belongs to the
//   displacement leg the CISD created, so it is a property of that leg's timeframe. On a
//   5m chart hunting a 5m CISD they are the same thing; drop to 1m and you still get the
//   5m gap rather than a cloud of 1m ones.
//   Direction-matched (bull CISD marks BISI, bear marks SIBI) and it inherits the suite's
//   fill law unchanged: a box comes off when price wicks ENTIRELY through it, never on a
//   touch. It also comes off when its parent setup dies, which costs no extra bookkeeping —
//   the layer reads the confirmed pool AFTER HuntCISD has pruned it.
// v4.34
// v4.34: LRLR — CLOSER HOVER + PER-DIRECTION TAG SIDE. Ryan, on both this and MM
//   IFVG: the line is still standing off too far, and he wants the tag on the
//   BOTTOM of the line for bearish runs and the top for bullish.
//   lrHover 0.3 -> 0.15 (it was 0.5 before v4.32, so this is the second step in).
//   ~0.1 is the practical floor: below that the line starts sitting ON its own
//   rungs, and a hovering line must never touch the points it was fitted through.
//   lrLblV split into lrLblBull / lrLblBear, paired on one inline row exactly like
//   the bull/bear colour inputs above it. Defaults are Ryan's preference — bull
//   Above, bear Below — which puts both tags on the side FACING the candles, since
//   a bearish line hovers above price and a bullish one below. The Left/Center/
//   Right anchor moves to its own row and keeps its own focused tooltip.
//   Mirrored hand-for-hand in MM IFVG v6.30 the same session.
// v4.33
// v4.33: LRLR LABEL PLACEMENT. Two dropdowns on one row — Above/Below the line, and
//   Left/Center/Right along it. Position reads straight off the line's own endpoints, so
//   the tag tracks a run that is still growing another rung. label_down hangs the body
//   ABOVE its anchor and label_up hangs it BELOW; the background is transparent, so the
//   pointer never shows and all you see is text sitting clear of the line.
//   Left anchors the tag at the run's ORIGIN — the far rung, the one price has to clear to
//   spend the whole ladder.
// v4.32: LRLR — LOCKED DOWN AND PULLED IN. Fifteen settings down to six. Ryan's call: a
//   PRO tool does the work, so everything tunable moved into a LOCKED CALIBRATION block —
//   sensitivity, the four-point floor, all three quality gates, hover, extend, run cap and
//   the HRLR fade. What is left is what a user legitimately owns: on/off, the setup gate,
//   two colours, width and style. Every locked value that could drift is ATR-scaled, so it
//   travels to other symbols and timeframes on its own, and a TradingView "reset settings"
//   can no longer undo the tuning. The full input set survives in the standalone MM LRLR,
//   which is where experimenting belongs — not a live PRO chart.
//   Also brought the line in closer, per Ryan: hover 0.5 -> 0.3 ATR and the outlier
//   tolerance 1.5 -> 1.2 sd. Near the rungs without touching them.
// v4.31: LRLR, GATED ON THE SETUP. Ported from MM LRLR v1.1 - same engine, same
//   calibration. A staircase of unswept swing extremes (4+ rungs, ATR-gated for swing
//   depth, scatter and steepness), drawn as a line that HOVERS clear of the rungs rather
//   than connecting them. Collected runs demote to a faded HRLR instead of vanishing.
//   Ryan's law: no LRLR prints unless a SETUP is present. Standalone, the tool answers
//   "is there a clean ladder somewhere" - a question nobody asked. Behind the gate it
//   answers the one that matters: the 4h C2 sealed, the 15m CISD confirmed, the SD ladder
//   is projecting - is the road to those targets actually clean?
//   Detection runs CONTINUOUSLY and only visibility is gated, because a staircase almost
//   always forms before the setup that makes it relevant; gating detection would mean the
//   run did not exist at the moment you needed it.
//   Ryan's law on the gate: an LRLR IS liquidity, and a setup is an attempt to RAID it, so
//   the test is the ladder's POSITION - a bull setup wants what is ABOVE. For an unswept
//   run that matches its direction anyway (a bearish ladder is above price or it would
//   have been collected); they diverge once collected, and position is the half that stays
//   honest - a swept ladder below a bull setup is stop reference, which is what HRLR means.
// v4.30: HTF FVG PRECEDENCE — HIGHER TIMEFRAME OWNS THE LEVEL. Ryan's ruling: when the two
//   slots produce gaps that OVERLAP IN PRICE, the higher-TF gap stays and the lower-TF one
//   comes off. 1D clears an overlapping 4H, 1H clears an overlapping 15m — ranked by actual
//   duration, never by slot number, so it holds whichever way you set the two dropdowns.
//   Works both directions in time: a new low-TF gap born under an existing high-TF gap is
//   never drawn, and a new high-TF gap clears the low-TF boxes already sitting on it.
//   Direction-agnostic (a bear 4H under a bull daily still yields — the argument is about
//   the level, not the side) and permanent, which is consistent with the fill rule: the
//   daily can only die by being wicked end to end, and that move wicks through whatever was
//   nested in it. Touching edges are not an overlap, so a 4H stacked directly on a daily's
//   boundary survives as its own gap.
// v4.29: HTF FVGs — THE MISSING BOXES. Symptom: with 4H + 1D slots on a 15m chart, a
//   whole run of daily FVGs never drew — you'd see one box and nothing above or below it,
//   including the gap price was actively trading in. Two independent causes, both fixed:
//   1. TIMING. Detection used barstate.isconfirmed inside the security tuple. That reads
//      true across the entire HTF context on historical bars, so the gap test ran against
//      the DEVELOPING HTF bar's running low/high — the flag could latch true mid-bar and
//      carry straight into the next HTF bar. The chart-side rising edge `flag and not
//      flag[1]` then never re-fired, so back-to-back gaps in a directional run (exactly
//      what NQ just did off the 7/30 low) drew ONE box for the whole streak. Now on
//      non-repaint idiom A — last CLOSED HTF bar via [1] + lookahead_on — firing when the
//      HTF bar's TIME changes. One evaluation per closed HTF bar, on finished data.
//   2. BUDGET. The box pool was capped at 60 across BOTH slots and evicted oldest-first,
//      so the busier slot (4H gaps ~10x as often as 1D) shifted out valid daily gaps.
//      Each slot now carries its own 40-box budget and can only evict its own oldest.
//   Ryan's law, now written into the code: an HTF FVG comes off the chart for ONE reason —
//   price wicked ENTIRELY through it. Not on a touch, not because a newer gap formed, not
//   because the other slot is busy. The full-fill removal rule itself was already correct
//   and is unchanged; what changed is everything that was removing boxes AROUND it.
// v4.28: ALERTS — FOURTEEN ROWS DOWN TO FOUR. Nine event checkboxes became ONE `Alert
//   level` dropdown, and the real problem was never the row count: the three
//   highest-frequency events were all ON by default, so the two alerts you actually trade
//   got buried under them.
//   TWO settings, split on one line: does the event belong to a SETUP, or not?
//     Setups only (default) — one setup start to finish. C2 confirmed → CISD armed OR
//       confirmed → retest → FC2, plus SD hits and stacked zones.
//     Everything — + HTF sweep developing, SMT, continuation. Ryan's three: the events that
//       belong to no particular setup. They are market context, and they are the loud ones.
//   Ryan's call on the two that moved: FC2 is PROMOTED into the default — the one alert
//   that says GET OUT was shipping OFF, which is backwards — and Continuation is DEMOTED,
//   because it fires to announce that nothing happened and must never buzz on a default
//   install. ARMED sits in the default too: since the same-bar suppression below it can
//   only fire when the setup is genuinely still hunting, so ARMED and CONFIRMED are now
//   mutually exclusive and exactly one of them lands per setup.
//   Message upgrades, both so the first alert of a setup is self-contained: C2 CONFIRMED
//   now quotes the extreme that invalidates it, and the CISD CONFIRMED message lists EVERY
//   SD projection instead of only the first two. The SD-hit message also carries the PAIR
//   tag now ("4h-15m SD 2.5 tagged") — it was the only CISD alert labelled with a single
//   timeframe, which read as though it fired off the C2 TF. It never did: that scan runs at
//   global scope, so hits are detected on the CHART's bars — the lowest reading available,
//   which is why the setup note tells you to build the alert on 1m.
//   NEW EVENT — C2 CLOSED, in every tier, off the HTF candle slots. Ryan wants the ping at
//   the C2 close itself because that is when he goes and looks at the chart, and the tiering
//   exposed that nothing else reliably announces it: ARMED needs a CISD level to have been
//   captured and CONFIRMED needs price through that level, so a C2 that seals with neither
//   was silent. After it fires, the rest of the flow is the CISD's — confirmed, retest, FC2.
//   Zero call sites touched: the bools are now DERIVED from the dropdown, so all 20+
//   f_alert(...) calls read exactly as they did. f_alert already gates on the master switch.
//   Also: ARMED no longer fires when the confirm lands on the same bar (since v4.27 a C2
//   sealing already through its level did both — one event was buzzing twice). And the
//   three ⓘ hover notes became one: "why 1m" and "how snapshot works" are the same topic
//   so they merged, and the model-pairing note moved up to HTF Slots where pairing lives.
// v4.27
// v4.27: MAULER MODE. v4.22 threw away every candle that took both of C1's sides. That was
//   too wide. A candle that sweeps one side and then closes BEYOND THE OTHER has not been
//   indecisive at all — the close named the side that got rejected, and it named it harder
//   than an ordinary close-back-inside C2 does. Only the outside candle that closes back
//   INSIDE C1's range is a coin flip, and that one is still skipped either way.
//   Ryan's law: sweep the low and close above C1's high = bullish C2. Sweep the high and
//   close below C1's low = bearish C2. Both sides taken AND the close lands back between
//   them = nothing to read, mark nothing.
//   This is KNOWINGLY a variation on standard behaviour, where a close through the opposite
//   side reads as expansion rather than a C2. So it is its own switch, `Mauler Mode`,
//   DEFAULT OFF (Ryan's call), named for what it is rather than smuggled into the two-sided
//   toggle. Ships running the standard model; the variation is opt-in.
//   Ryan's usage rule, carried in the tooltip: only take these when the swept extreme (the
//   C2 low on a bull, the C2 high on a bear) TAPS A PD ARRAY — FVG, OB, breaker, etc. The
//   close through the far side is the displacement; the PD array is the reason it happened.
//   Without one you are trading a big candle, not a setup.
//   No side-picking logic was needed: a close above C1's high can only satisfy the bullish
//   test, a close below C1's low only the bearish one, so the candle resolves to exactly one
//   direction on its own. Applied at all three C2 claims together (sweep mark, SMT per
//   symbol, CISD engine) so they cannot disagree, plus the continuation alert, which must
//   no longer call that candle "expansion, no C2" now that it IS one.
//   SEPARATELY, AND APPLYING TO EVERY C2 — POTENTIAL NOW MEANS WHAT IT SAYS.
//   Ryan's law: a CISD is POTENTIAL for one reason only — price has not closed through the
//   level yet. It is NOT a statement about which candle the close belonged to. The confirm
//   test only accepts bars in C3 or later (~line 1017, so the C2's own final bar can never
//   confirm it), which quietly made "potential" mean "hasn't closed through IN C3", and that
//   is a different claim.
//   The case that exposes it: a 4H C2 seals at 10:00 and the 15m bar whose close SEALS it
//   also closes above the CISD. Both true at the same instant — but the confirm path can't
//   see that bar, so the level drew POTENTIAL until ~10:30 while price was already through
//   it and retesting. It now confirms at the seal, off the C2's own closing price, and draws
//   confirmed immediately. A C2 that seals with price NOT yet through still arms potential
//   and upgrades on the first later LTF close through, exactly as before.
//   Checked at the SEAL and not mid-C2 on purpose: the CISD anchor re-anchors every time
//   price sweeps a new low inside the candle, so an earlier bar would be confirming against
//   a level that no longer exists by the close. At the seal both are final.
//   This is model-neutral — nothing to do with Mauler Mode, it fixes the normal C2 too.
// v4.26
// v4.26: A DEAD HUNT LEAVES A FOOTPRINT, AND THE TABLE SAYS THINGS IN WORDS.
//   Three changes, all about the table and what is drawn on price agreeing.
//   (1) A hunt that dies (C2 extreme taken before the CISD ever confirmed) used to have
//   its line DELETED outright. With Potential CISD on that means you watch a dashed level
//   get hunted and then simply evaporate with no reason given. It is now stubbed, greyed
//   and marked with a muted × at the C2 extreme that killed it — the same treatment FC2
//   gets, one volume level down, and no alert. See KillHuntMark.
//   (2) The table splits the two failures: FC2 (confirmed, then invalidated — you could
//   have been in it, red, matching the red tag) vs Fail (died while hunting, never gave an
//   entry, muted, matching the ×). One word per event, one mark per word.
//   (3) New State row spells out Confirmed / Unconfirmed for the CISD. A C2 can be sealed
//   while its CISD has not been closed through yet, and that distinction was previously
//   carried only by the shade of the level's text — unreadable to anyone who has not been
//   told the colour code. Ryan's call: say it in words.
// v4.25
// v4.25: TABLE NOW FAILS A SETUP THE SAME WAY THE CHART DOES. The v4.24 table could only
//   learn that a setup was over from the engine's active flag dropping — which happens at
//   CONFIRMATION. So a setup that confirmed and THEN had its C2 extreme taken inside C3
//   (the chart stubs its line, drops the label and tags it red FC2, see
//   InvalidateConfirmed ~1008) went on reading as a live setup in the table. Table said
//   Yes/Bear/C3, chart said stand down.
//   The table now runs the SAME breach test itself, off the C2 extreme the engine already
//   returns: a raw trade through it (wick counts) INSIDE C3 is a failure and the column
//   reads FC2 in the bear colour; the same breach inside C4 is not a failure, the sequence
//   is simply over and the column clears.
//   Ryan's law: any C3 push past the C2 extreme fails the setup, wick or close. The same
//   push in C4 does not fail it — that setup is just finished. The table and what is drawn
//   on price have to agree at all times.
// v4.24
// v4.24: CISD TABLE REBUILT AROUND THE C2 SEQUENCE. Two things were wrong with it.
//   (1) "C2 conf" was wired to the engine's bullActive/bearActive flag, which is a
//   LIVE-HUNT flag, not a fact about C2 — it goes false on BOTH outcomes (see ~897:
//   confirm and kill clear it alike). A setup whose CISD confirmed and delivered read
//   exactly the same "No" as a timeframe that never had a C2 at all, so the table went
//   quiet precisely when a setup WORKED.
//   (2) It had no concept of how far along the setup was, so a C2 from hours back looked
//   identical to one that just sealed.
//   Now TRANSPOSED — timeframes across the top (two or three, selectable), fields down
//   the side: C2 conf · Dir · CISD · Seq. The column opens at C3 when a C2 seals, steps
//   to C4, and STOPS. If the candle after C4 is not itself a new C2, the column blanks
//   and waits. The CISD cell carries the outcome in its colour (hunting / confirmed /
//   killed) so a failed setup keeps counting to C4 instead of vanishing mid-run.
//   Ryan's law: the sequence runs C3 → C4 and ends. A played-out setup is history, and
//   history does not belong on a live readout.
//   No engine change — huntCount / confCount were already in the returned tuple and
//   were being destructured and thrown away.
// v4.23
// v4.23: CISD table fully customizable - background, frame, grid, title/value colours,
//   bull/bear/muted, plus a column-titles toggle. Also fixes a latent bug in v4.21:
//   position and frame colours were passed to `table.new`, which is `var`, so they were
//   fixed on the first bar and silently ignored every settings change after it. They are
//   now SET on the last bar instead.
// v4.22
// v4.22: TWO-SIDED SWEEPS SKIPPED. A candle that takes BOTH the prior candle's high
//   and its low is indecisive - it took liquidity from both sides, so nothing was
//   rejected and there is no side to read. The engine used to pick whichever side the
//   close favoured, which drew as a clean sweep when it was the opposite. Gated by
//   `Skip two-sided sweeps` (on) across ALL THREE places a C2 is claimed - the sweep
//   mark, SMT (per symbol, so a correlate's outside bar doesn't count either) and the
//   CISD engine - so they can never disagree about the same candle.
//   Ryan's ruling. The two-sided case has no settled convention either way, which is why
//   it's a switch rather than baked in.
// v4.21
// v4.21: CISD Table — a four-column readout (C2 TF · C2 confirmed · direction · CISD
//   level) on its own TWO timeframes, picked in C2 terms and paired to the CISD off
//   the pair table. Deliberately DECOUPLED from the CISD selectors: those decide
//   what draws on the chart, this decides what you can read. Run one CISD on price
//   and still watch two setups here. Read-only — it draws nothing on the chart.
indicator('Maulers Raid to Delivery', 'MM RTD', overlay = true, max_boxes_count = 500, max_lines_count = 500, max_labels_count = 500)

// v4.55: RE10143 FIX (live error 27/9/2026 on bar 20493: "requested historical offset (1600)
// is beyond the historical buffer's limit (1599)" at the LRLR label.set_xy).
// WHY a line with no [n] throws a history error: a drawing given xloc.bar_index coords has
// them converted to TIME internally, so an x far back is a hidden time[bar_index - x] read.
// Pine sizes the buffer by scanning the source for offsets it can see; a runtime anchor is
// invisible to that scan, so the guess ran out mid-chart. Two parts, both needed:
//   1. max_bars_back(time, 5000) sizes the buffer for `time` only (the one series involved).
//      NOT the indicator() parameter, which would size every series and slow the load.
//   2. Every far-back clamp now uses MAX_BACK = 4900, under the 5,000 buffer ceiling. The
//      old 9,500 clamp guarded the ~10,000-bar drawing limit but was still past the buffer.
// It stopped the whole script, so every alert died with it.
max_bars_back(time, 5000)
int MAX_BACK = 4900

// Fractal Model SMT + CISD on HTF candles.
// A C2 prints when the current HTF candle raids the prior candle's (C1) high or low
// and closes back inside. SMT fires when the chart asset raids its C1 level while at
// least one correlated asset holds its own C1 level.
//
// CISD (fractal pair): once an HTF C2 closes confirmed, the paired LTF is hunted for
// a Change In State of Delivery. Bullish level = open of the FIRST down-close candle
// of the run that swept C1's low; bearish = open of the first up-close candle of the
// rally that swept C1's high. Confirms on the first LTF CLOSE through the level after
// the C2 close. Hunt dies only if price takes the C2's own extreme. Requires the
// chart timeframe to be at or below the pair LTF (e.g. 4h C2 -> hunt 15m CISD from
// a 15m or lower chart).
//
// Alerts: alert()-based events (CISD armed/confirmed/retest, FC2 fail, mini sweeps, mini SMT).
// Enable in the Alerts group, then create ONE TradingView alert on this indicator with
// condition "Any alert() function call" - it carries every ticked event for all slots.
//
// v4.0 RENDER CORE. The HTF mini candles are pure DATA: each slot keeps its candle
// history in parallel primitive arrays (index 0 = oldest, last = the developing
// candle, every candle carries a persistent id). Drawings are disposable projections:
// on the last bar the whole lane is wiped and rebuilt from the data by f_render, in
// z-order (imbalances, bodies/wicks, marks, labels). No drawing coordinate is ever
// read back to position another drawing, and no per-object mutate/reorder pass exists.
// Sweep and SMT marks anchor to candle IDS and are resolved to lane x positions at
// draw time; marks whose candles age off are purged with them.

// - - - - - - - - -
// Constants

int CW = 2                      // candle body width in chart bars (lane geometry unit)
color COL_NONE = #ffffff00

// - - - - - - - - -
// Types

// One HTF slot. Candle history as parallel primitive arrays; drawings in three flat
// disposable pools that f_render wipes and refills.
type Slot
    array<float> o
    array<float> h
    array<float> l
    array<float> c
    array<int> id
    array<string> tag
    int nextId = 0
    bool raidHi = false
    bool raidLo = false
    int devOIdx = 0
    int devHIdx = 0
    int devLIdx = 0
    // close-confirmed sweep marks: C2 id, C1 id, raided level
    array<int> swC2
    array<int> swC1
    array<float> swLvl
    // v4.49: which side C2 raided. true = swept C1's LOW (bull setup). Drives which side
    // of the lane the C1/C2/C3 tags sit on. Parallel with the three above it - clear,
    // push and remove all four together or a triple ends up on the wrong side.
    array<bool> swBull
    // SMT marks between two minis: from id, to id, wick y's, side, correlate tag
    array<int> smFrom
    array<int> smTo
    array<float> smY1
    array<float> smY2
    array<bool> smBear
    array<string> smSym
    // disposable drawing pools
    array<box> dB
    array<line> dL
    array<label> dT

// One confirmed setup's std-dev ladder. v4.19: ladders used to live in ONE flat pool per
// book, located by `setupIndex * perSet` arithmetic. The instant that pool and the done pool
// drifted by a single entry the arithmetic stopped lining up, every ladder hide silently
// became a no-op, and a stacked zone showed two ladders at once. A setup now OWNS its
// drawings, so there is no mapping left to drift.
type SdLadder
    array<line> lines
    array<label> lbls
    // v4.44: one latch per level, index-parallel with `lines`. Owns the SD-hit alert's
    // fired/not-fired state so f_sdHitScan never has to search for it. Parallel-array
    // discipline applies: anything that pushes to `lines` pushes here on the same pass.
    array<bool> hits
    // v4.45: the trade plan's two drawings. They hang here rather than in CisdBook because
    // the ladder's lifetime is ALREADY the setup's lifetime and every removal path in the
    // file calls f_clearLadder. Parallel columns on the done pool would have meant editing
    // each removal site and betting on never missing one - the exact failure the "EVERY
    // column, or the rows desync" rule exists for.
    line sl
    label slLbl

// One CISD selector's chart-side state (hunt lines, confirmed/failed pools, SD
// ladders, debug marks). Fully decoupled from the candle slots.
type CisdBook
    line cisd_bull_line
    line cisd_bear_line
    label cisd_bull_lbl
    label cisd_bear_lbl
    array<line> cisd_done_lines
    array<label> cisd_done_lbls
    array<float> cisd_done_c2
    array<int> cisd_done_dir
    array<int> cisd_done_c3end
    array<int> cisd_done_state
    array<bool> cisd_done_stack
    array<line> cisd_failed_lines
    array<label> cisd_failed_lbls
    int cisd_bull_hunt_count = 0
    int cisd_bull_conf_count = 0
    int cisd_bear_hunt_count = 0
    int cisd_bear_conf_count = 0
    int cisd_newest_start = na
    // v4.53: opposing-setup tags, and the latch that makes each one fire exactly once.
    // Latched by the marked setup's CISD-line x1 - the same identity CapSetups ages setups
    // by - so a conflict that persists for fifty bars still only ever prints one tag.
    array<label> cisd_opp_lbls
    int cisd_opp_marked = 0
    // One ladder per confirmed setup, index-parallel to cisd_done_lines
    array<SdLadder> cisd_done_sd
    // v4.19: a hunt captures its leg anchors when it ARMS and the ladder is built at
    // CONFIRM from these - geometry identical to the old arm-time draw, it just does not
    // exist while the setup is still only potential. A dead hunt leaves nothing behind.
    float cisd_sdB_a0 = na
    float cisd_sdB_a1 = na
    int cisd_sdB_st = na
    float cisd_sdS_a0 = na
    float cisd_sdS_a1 = na
    int cisd_sdS_st = na

f_newSlot() =>
    Slot s = Slot.new()
    s.o := array.new<float>()
    s.h := array.new<float>()
    s.l := array.new<float>()
    s.c := array.new<float>()
    s.id := array.new<int>()
    s.tag := array.new<string>()
    s.swC2 := array.new<int>()
    s.swC1 := array.new<int>()
    s.swLvl := array.new<float>()
    s.swBull := array.new<bool>()
    s.smFrom := array.new<int>()
    s.smTo := array.new<int>()
    s.smY1 := array.new<float>()
    s.smY2 := array.new<float>()
    s.smBear := array.new<bool>()
    s.smSym := array.new<string>()
    s.dB := array.new<box>()
    s.dL := array.new<line>()
    s.dT := array.new<label>()
    s

f_newBook() =>
    CisdBook b = CisdBook.new()
    b.cisd_done_lines := array.new<line>()
    b.cisd_done_lbls := array.new<label>()
    b.cisd_done_c2 := array.new<float>()
    b.cisd_done_dir := array.new<int>()
    b.cisd_done_c3end := array.new<int>()
    b.cisd_done_state := array.new<int>()
    b.cisd_done_stack := array.new<bool>()
    b.cisd_failed_lines := array.new<line>()
    b.cisd_failed_lbls := array.new<label>()
    b.cisd_done_sd := array.new<SdLadder>()
    b.cisd_opp_lbls := array.new<label>()
    b

var Slot slot1 = f_newSlot()
var Slot slot2 = f_newSlot()
var Slot slot3 = f_newSlot()

var CisdBook bookA = f_newBook()
var CisdBook bookB = f_newBook()
var CisdBook bookC = f_newBook()

// - - - - - - - - -
// Inputs

string GRP_SLOTS = 'HTF Candle Lanes'
string GRP_STYLE = 'Styling'
string GRP_LBL = 'Labels'
string GRP_IMB = 'Imbalances & Sweeps'
string GRP_TRACE = 'Trace'

in_s1_on = input.bool(true, 'HTF 1      ', inline = 's1', group = GRP_SLOTS)
in_s1_tf = input.timeframe('60', '', inline = 's1', group = GRP_SLOTS)
in_s1_n = input.int(7, '', minval = 2, maxval = 20, inline = 's1', group = GRP_SLOTS, tooltip = 'Slot timeframe, and how many candles to keep. A slot draws only when its TF is ABOVE the chart TF and divides evenly into it (1D and higher always qualify above the chart TF). An off-multiple pick, like 90m minis on a 7m chart, stays hidden - switch the chart TF or the slot TF.')

in_s2_on = input.bool(true, 'HTF 2      ', inline = 's2', group = GRP_SLOTS)
in_s2_tf = input.timeframe('240', '', inline = 's2', group = GRP_SLOTS)
in_s2_n = input.int(7, '', minval = 2, maxval = 20, inline = 's2', group = GRP_SLOTS)

in_s3_on = input.bool(true, 'HTF 3      ', inline = 's3', group = GRP_SLOTS)
in_s3_tf = input.timeframe('D', '', inline = 's3', group = GRP_SLOTS)
in_s3_n = input.int(7, '', minval = 2, maxval = 20, inline = 's3', group = GRP_SLOTS)

in_maxsetups = input.int(0, 'Max setups', minval = 0, maxval = 10, group = GRP_SLOTS, tooltip = 'Extra C2+CISD setups kept per HTF slot beyond the most recent one. 0 = only the latest setup per slot, 1 = two each, 2 = three each, etc. Applies to each of the three CISD slots independently.')
in_customd_on = input.bool(false, 'Custom daily candle open     ', inline = 'customdaily', group = GRP_SLOTS, tooltip = 'Re-times the 1D minis to open at the chosen time instead of the exchange boundary. Side effect: SMT is suppressed on a 1D slot while this is on, because the re-timed candles would no longer pair with the correlated symbols candle-for-candle.')
in_customd = input.string('Midnight', '', options = ['Midnight', '8:30', '9:30'], inline = 'customdaily', group = GRP_SLOTS)

// v4.28: moved here from the Alerts group. Display-only hover note - carries NO logic,
// it exists purely as a tooltip anchor. It is about pairing SLOTS, which is this group's
// job; it was never an alerts topic and only lived there by accident.
bool in_modelnote = input.bool(false, 'ⓘ  How to set up a model', group = GRP_SLOTS, tooltip = 'THERE ARE TWO SEPARATE SYSTEMS HERE. Get this and the rest is easy.\n\nCANDLE SLOTS (this group)\nDraw the HTF candles, and fire the "C2 CONFIRMED - go look" alert. One per slot.\n\nCISD SELECTORS (the CISD group)\nRun the hunt on that C2: armed, confirmed, retest, failed, targets.\n\nYou need BOTH for a full setup. A slot with no matching CISD gives you "a C2 closed" and then silence.\n\n─────────────\nPICK THE ENTRY TF, NOT THE C2\nA CISD selector is the timeframe you ENTER on. Its C2 comes with it automatically:\n\n  1m CISD   <-  15m C2\n  3m CISD   <-  30m C2\n  5m CISD   <-  1h C2\n  15m CISD  <-  4h C2\n  1h CISD   <-  1D C2\n\nSo "I trade the 4h into a 15m entry" = set a 15m CISD. There is no 4h to pick anywhere.\n─────────────\n\nEXAMPLE\nFull setups on 4h-15m and 1h-5m, plus a heads-up when a 15m C2 closes but nothing after it:\n\n  Candle slots :  4h,  1h,  15m\n  CISD 1 :  15m      (runs the 4h setup)\n  CISD 2 :  5m       (runs the 1h setup)\n  CISD 3 :  Off\n\nThe 15m slot gives you the C2 ping and nothing follows - that is intended, no CISD is watching it. To actually hunt a 15m C2 you would set a 1m CISD.\n\nCHART TIMEFRAME RULE\nA CISD only runs when your chart is at or below it. A 5m CISD is silent on a 15m chart. Build your alert on 1m and every CISD runs.')

// Ryan's law (v4.22): a candle that takes BOTH the prior candle's high AND its low is
// INDECISIVE. Nothing was rejected in either direction, so there is no side to read and
// no C2 to mark. Model-wide - it gates the sweep marks, SMT and the CISD engine
// together, because they all rest on the same claim and must not disagree.
in_nobothsw = input.bool(true, 'Skip two-sided sweeps', group = GRP_SLOTS, tooltip = 'A candle that sweeps the prior candle\'s HIGH and its LOW took liquidity from both sides. Neither side was rejected, so there is no direction to read - and nothing gets marked.\n\nTurn it off and the engine picks whichever side the close favours. That draws as a clean sweep when it was not one.\n\nSweep marks, SMT and CISD all skip the candle together, so they can never disagree about it.')

// Ryan's law (v4.27): the one two-sided candle that is NOT indecisive is the one that
// closes BEYOND the opposite extreme. Sweep the low, close above C1's high, and the close
// has named the rejected side harder than a normal close-back-inside C2 ever does.
// DEFAULT OFF (Ryan's call). Standard behaviour reads a close through the far side as
// expansion rather than a C2, so the tool ships that way and this is an opt-in. The usage
// rule (swept extreme must tap a PD array) lives in the tooltip because that is where it
// will actually get read.
in_mauler = input.bool(false, 'Mauler Mode', group = GRP_SLOTS, tooltip = 'OFF BY DEFAULT. An in-house variation - leave it off for standard behaviour.\n\nWHAT IT ADDS\nOne setup: a candle that sweeps ONE side of C1 and closes THROUGH the other.\nBull - trades below C1\'s low, closes above C1\'s high.\nBear - the mirror.\n\nThe rule above throws that candle out for taking both sides. This keeps it: the sweep took liquidity and the close ran the whole range the other way.\n\nHOW TO USE IT\nHighly recommended: only take these when the C2 taps a MAJOR liquidity area - the C2 LOW on a bull, the C2 HIGH on a bear. A PD array sitting at that extreme (FVG, order block, breaker) is what you want to see.\n\nNo level, no trade. The indicator will not filter that for you.\n\nEXPECT\nUsually reads Confirmed the moment the C2 seals, so your entry is the retest - not the break.\n\nStill expect failures. A clean tap is not a guarantee. This is a variation, not a higher-probability setup.\n\nInvalidation is wide. The C2 extreme is the far end of a candle that ran the whole prior range. Size accordingly.')

in_bull_body = input.color(color.new(#ffffff, 10), 'Body  ', inline = 'body', group = GRP_STYLE)
in_bear_body = input.color(color.new(#b8b8b8, 10), '', inline = 'body', group = GRP_STYLE)
in_bull_border = input.color(color.new(#000000, 0), 'Borders', inline = 'borders', group = GRP_STYLE)
in_bear_border = input.color(color.new(#000000, 0), '', inline = 'borders', group = GRP_STYLE)
in_bull_wick = input.color(color.new(#000000, 0), 'Wick  ', inline = 'wick', group = GRP_STYLE)
in_bear_wick = input.color(color.new(#000000, 0), '', inline = 'wick', group = GRP_STYLE)
in_pad = input.int(20, 'Gap from price (bars)', minval = 1, group = GRP_STYLE)
in_gap = input.int(1, 'Space between candles', minval = 1, maxval = 4, group = GRP_STYLE)
in_slotgap = input.int(10, 'Space between timeframes', minval = 1, maxval = 10, group = GRP_STYLE)

in_tag_on = input.bool(true, 'HTF Label           ', inline = 'tfl', group = GRP_LBL)
in_tag_col = input.color(color.new(#000000, 0), '', inline = 'tfl', group = GRP_LBL)
in_tag_size = input.string(size.small, '', [size.tiny, size.small, size.normal, size.large, size.huge], inline = 'tfl', group = GRP_LBL)
in_tag_pos = input.string('Both', 'Label Positions', options = ['Both', 'Top', 'Bottom'], group = GRP_LBL)
in_tag_align = input.string('Align', 'Label Alignment', options = ['Align', 'Follow Candles'], group = GRP_LBL)
in_tmr_on = input.bool(true, 'Remaining time      ', inline = 'tmr', group = GRP_LBL, tooltip = 'Candle-close countdown per slot. Reads (n/a) until the first live tick after loading - countdowns need realtime data, historical bars have none.')
in_tmr_col = input.color(color.new(#000000, 0), '', inline = 'tmr', group = GRP_LBL)
in_tmr_size = input.string(size.small, '', [size.tiny, size.small, size.normal, size.large, size.huge], inline = 'tmr', group = GRP_LBL)
in_dow_on = input.bool(false, 'Interval Value        ', inline = 'dow', group = GRP_LBL)
in_dow_col = input.color(#666666, '', inline = 'dow', group = GRP_LBL)
in_dow_size = input.string(size.small, '', [size.tiny, size.small, size.normal, size.large, size.huge], inline = 'dow', group = GRP_LBL)

in_c123_on = input.bool(true, 'C1 / C2 / C3          ', inline = 'c123', group = GRP_LBL)
in_c123_col = input.color(color.new(#000000, 0), '', inline = 'c123', group = GRP_LBL)
in_c123_fcol = input.color(color.new(#000000, 0), '', inline = 'c123', group = GRP_LBL)
in_c123_size = input.string(size.tiny, '', [size.tiny, size.small, size.normal, size.large, size.huge], inline = 'c123', group = GRP_LBL, tooltip = 'Names each mini candle\'s place in the sequence, tagged under its low wick.\n\nC1 is the candle that got raided. C2 is the one that raided it and closed back inside. C3 is the candle after C2 - the one you are hunting the CISD in.\n\nF3 replaces C3 when that candle takes the C2 extreme - below C2\'s low on a bull setup, above C2\'s high on a bear. That is the same break the FC2 alert reports, so the tag and the alert always agree. It flips live while C3 is still forming, and it sticks once the candle closes.\n\nTwo colours: the first is C1/C2/C3, the second is F3.\n\nThe tags follow the SETUP, not the candle colour. A bull setup raided C1\'s LOW, so its tags hang under the lows. A bear setup raided C1\'s HIGH, so its tags sit above the highs.\n\nONE set per slot, on the most recent C2. When a new C2 closes, the tags move to it. Independent of the HTF Sweeps toggle: turning the sweep LINE off leaves these where they are.')

in_fvg_on = input.bool(true, 'Fair Value Gap   ', inline = 'fvg', group = GRP_IMB)
in_fvg_col = input.color(color.new(#000000, 90), '', inline = 'fvg', group = GRP_IMB)
in_vi_on = input.bool(true, 'Volume Imbalance', inline = 'vi', group = GRP_IMB)
in_vi_col = input.color(color.new(color.red, 50), '', inline = 'vi', group = GRP_IMB)
in_sw_on = input.bool(true, 'HTF Sweeps      ', inline = 'sweep', group = GRP_IMB, tooltip = 'THIS ONLY TURNS OFF THE LINE.\n\nOff, the sweep level stops being DRAWN on the mini panel. Everything that depends on a C2 having closed keeps running exactly as before:\n\n  • the C2 CONFIRMED alert still fires\n  • the CISD hunt still arms on it\n  • the C1 / C2 / C3 tags in the Labels group still mark it\n\nWhether a C2 closed is not a drawing question. Use this when you want the panel cleaner, not when you want to stop watching sweeps.')
in_sw_col = input.color(color.new(#000000, 0), '', inline = 'sweep', group = GRP_IMB)
in_sw_style = input.string('⎯⎯⎯', '', options = ['⎯⎯⎯', '----', '····'], inline = 'sweep', group = GRP_IMB, tooltip = 'Marks a mini candle that RAIDED its prior candle\'s high or low and CLOSED back inside - the sweep that sets up a C2. The line sits at the level that got raided and runs from the candle that made it to the candle that took it.\n\nONE mark per timeframe: each mini panel shows only its most recent sweep, so the level you are looking at is always the live one. A new sweep replaces it.')

// Trace lines, HTF labels and the countdown follow fadizeidan's "ICT HTF Candles [Source Code]" (MPL 2.0).
in_trace_on = input.bool(false, 'Trace lines', group = GRP_TRACE)
in_tr_o_col = input.color(color.new(color.gray, 50), 'Open    ', inline = 't1', group = GRP_TRACE)
in_tr_o_sty = input.string('····', '', options = ['⎯⎯⎯', '----', '····'], inline = 't1', group = GRP_TRACE)
in_tr_o_w = input.int(1, '', options = [1, 2, 3, 4], inline = 't1', group = GRP_TRACE)
in_tr_c_col = input.color(color.new(color.gray, 50), 'Close    ', inline = 't2', group = GRP_TRACE)
in_tr_c_sty = input.string('····', '', options = ['⎯⎯⎯', '----', '····'], inline = 't2', group = GRP_TRACE)
in_tr_c_w = input.int(1, '', options = [1, 2, 3, 4], inline = 't2', group = GRP_TRACE)
in_tr_h_col = input.color(color.new(color.gray, 50), 'High     ', inline = 't3', group = GRP_TRACE)
in_tr_h_sty = input.string('····', '', options = ['⎯⎯⎯', '----', '····'], inline = 't3', group = GRP_TRACE)
in_tr_h_w = input.int(1, '', options = [1, 2, 3, 4], inline = 't3', group = GRP_TRACE)
in_tr_l_col = input.color(color.new(color.gray, 50), 'Low     ', inline = 't4', group = GRP_TRACE)
in_tr_l_sty = input.string('····', '', options = ['⎯⎯⎯', '----', '····'], inline = 't4', group = GRP_TRACE)
in_tr_l_w = input.int(1, '', options = [1, 2, 3, 4], inline = 't4', group = GRP_TRACE)
in_tr_anchor = input.string('First Timeframe', 'Anchor to', options = ['First Timeframe', 'Last Timeframe'], group = GRP_TRACE)
in_pxlbl_on = input.bool(false, 'Price Label           ', inline = 'pxl', group = GRP_TRACE)
in_pxlbl_col = input.color(color.new(#666666, 10), '', inline = 'pxl', group = GRP_TRACE)
in_pxlbl_size = input.string(size.small, '', [size.tiny, size.small, size.normal, size.large, size.huge], inline = 'pxl', group = GRP_TRACE)

string GRP_EQL = 'Equal Highs / Lows'
showEql = input.bool(true, 'Show Equal Highs/Lows', group = GRP_EQL)
eqlMinBars = input.int(0, 'Minimum Bar Distance', minval = 0, maxval = 500, group = GRP_EQL, tooltip = 'Minimum bars between the candles forming an equal high/low')
eqlTolTicks = input.int(0, 'Tolerance (ticks)', minval = 0, maxval = 50, group = GRP_EQL, tooltip = '0 = exact wick match. Increase to allow near-equal wicks to count as an EQH/EQL.')
eqhColor = input.color(#000000, 'EQH Color', inline = 'eqlC', group = GRP_EQL)
eqlColor = input.color(#000000, 'EQL Color', inline = 'eqlC', group = GRP_EQL)
eqlLineStyleStr = input.string('Solid', 'Line Style', options = ['Solid', 'Dashed', 'Dotted'], inline = 'eqlL', group = GRP_EQL)
eqlLineWidth = input.int(1, 'Width', minval = 1, maxval = 5, inline = 'eqlL', group = GRP_EQL)
eqlExtendRight = input.bool(false, 'Extend Right', group = GRP_EQL, tooltip = 'Extend EQH/EQL lines to the right edge of the chart')

string GRP_HFVG = 'Higher Timeframe FVGs'
useHf1 = input.bool(true, 'HTF FVG 1', inline = 'hf1', group = GRP_HFVG, tooltip = 'Higher-timeframe FVGs need enough chart history to see the HTF candles, so how deep they reach depends on your chart TF and TradingView plan. Rough guide on a 23h futures symbol with Premium (20,000 bars): every option works on a 1m chart or above. 1D thins out on 30s charts and will NOT draw below that; 4H holds down to about 5s. Plans below Premium have half the history or less, so limits hit sooner. If a slot looks empty: raise your chart timeframe or pick a lower HTF - nothing is broken, the history just does not contain the candles.\n\nSAME TIMEFRAME AS YOUR CHART: a slot hides when its timeframe equals your chart\'s (1H slot on a 1H chart), because those gaps are already on the chart. Turn on Include chart timeframe below to draw it anyway.')
hf1Sel = input.string('1H', '', options = ['15m', '1H', '4H', '1D'], inline = 'hf1', group = GRP_HFVG)
hf1Bull = input.color(color.new(#4caf50, 75), '', inline = 'hf1', group = GRP_HFVG)
hf1Bear = input.color(color.new(#000000, 72), '', inline = 'hf1', group = GRP_HFVG)
useHf2 = input.bool(true, 'HTF FVG 2', inline = 'hf2', group = GRP_HFVG, tooltip = 'Higher-timeframe FVGs need enough chart history to see the HTF candles, so how deep they reach depends on your chart TF and TradingView plan. Rough guide on a 23h futures symbol with Premium (20,000 bars): every option works on a 1m chart or above. 1D thins out on 30s charts and will NOT draw below that; 4H holds down to about 5s. Plans below Premium have half the history or less, so limits hit sooner. If a slot looks empty: raise your chart timeframe or pick a lower HTF - nothing is broken, the history just does not contain the candles.\n\nSAME TIMEFRAME AS YOUR CHART: a slot hides when its timeframe equals your chart\'s (1H slot on a 1H chart), because those gaps are already on the chart. Turn on Include chart timeframe below to draw it anyway.')
hf2Sel = input.string('1D', '', options = ['15m', '1H', '4H', '1D'], inline = 'hf2', group = GRP_HFVG)
hf2Bull = input.color(color.new(#4caf50, 75), '', inline = 'hf2', group = GRP_HFVG)
hf2Bear = input.color(color.new(#000000, 72), '', inline = 'hf2', group = GRP_HFVG, tooltip = 'Bull / bear box colors per slot. Boxes draw where the 3-candle pattern formed and stay on the chart until they fill - a touch, or a tap that reverses, leaves the gap up. What counts as filled is set by Fill Type below (Wick by default).\n\nWith both slots on, the higher timeframe owns the level: if a 1D gap and a 4H gap overlap in price, the 1D stays and the 4H comes off (same for 1H over 15m). You get one box per level, on the timeframe that matters, instead of the same imbalance drawn twice.')
// v4.54: Ryan's law - a slot on the chart's own timeframe hides by default (its gaps are already
//   on the chart), but the user can opt in. Default OFF keeps every existing chart identical.
hfSameTf = input.bool(false, 'Include chart timeframe', group = GRP_HFVG, tooltip = 'OFF (default): a slot only draws when its timeframe is HIGHER than your chart. A 1H slot on a 1H chart hides, because those gaps are already on the chart as regular FVGs.\n\nON: a slot also draws when its timeframe EQUALS your chart, so a 1H slot on a 1H chart shows its boxes. A slot set LOWER than your chart still never draws.')
// v4.54: Fill Type (Ryan, 25/9/2026). Wick keeps the v4.30 full-fill rule exactly. Close lets a gap
//   survive a wick through the far edge and only removes it when a candle of THE GAP'S OWN TIMEFRAME
//   closes beyond it (a 1D gap needs a daily close). Judged on the closed HTF candle, never intrabar.
string hfFill = input.string('Close', 'Fill Type', options = ['Wick', 'Close'], group = GRP_HFVG, tooltip = 'When a higher-timeframe FVG counts as filled and comes off the chart.\n\nWICK: the moment price trades ENTIRELY through the gap, even on a wick.\n\nCLOSE (default): a wick through is ignored. The gap only comes off when a candle of the GAP\'S OWN TIMEFRAME closes beyond its far edge - a 1D gap needs a daily close below its bottom (bull) or above its top (bear), a 1H gap needs an hourly close. Your chart timeframe does not matter. Use this to keep gaps that were run but never accepted.\n\nJudged once, when that higher-timeframe candle has closed, so it never removes a gap mid-candle.')
hfTxtCol = input.color(#000000, 'Label', group = GRP_HFVG, tooltip = 'Color of the timeframe label inside the HTF FVG boxes.')

string GRP_PCF = 'CISD FVG'
pcOn = input.bool(false, 'Show CISD FVG', group = GRP_PCF, tooltip = 'Marks the fair value gap sitting ON your CISD level - the first one to open after the CISD confirms, on the CISD\'s own timeframe.\n\nONE BOX PER SETUP. Not every gap in the leg. Gaps further up the run are just gaps, and MM Gaps / FVG Finder already mark those; this layer only answers the question about the level.\n\nThis is a marking layer and nothing else. It does not change the setup, the CISD, the targets, or anything about how the rest of this indicator behaves. Turn it on and you see more; turn it off and everything else is identical.\n\nWHY YOU WANT TO SEE IT. A confirmed CISD often never gets retested, and the gap sitting on it is usually why - price does not come back down for the level while there is unfilled imbalance holding it up. Example: the 4H C2 seals bull, the 5m CISD confirms, and a 5m BISI opens right above it. That gap is the floor price is standing on, so the level below it never gets touched. Without this layer you work that out after the move; with it you are looking at it while it happens.\n\nWhat you do with that is entirely yours. The tool marks the gap. It does not tell you to do anything.')
pcBull = input.color(color.new(#26c6da, 80), 'Bull / Bear ', inline = 'pcc', group = GRP_PCF, active = pcOn)
pcBear = input.color(color.new(#ff7043, 80), '', inline = 'pcc', group = GRP_PCF, active = pcOn, tooltip = 'Box colours per direction. The colour picker\'s OPACITY slider is the opacity control - drag it up to make a gap solid enough to read at a glance, down to leave it as a whisper over the candles. Defaults are deliberately faint, because this layer sits on top of price action you still need to see.\n\nOnly the gap that AGREES with the CISD is drawn. A bull CISD marks BISI, a bear CISD marks SIBI. The opposite-side gap inside the same leg is not the thing being pointed at, so it stays off the chart.\n\nA gap that STRADDLES the level still counts - a CISD sitting inside its own gap is the strongest version of this, not a reason to skip it.\n\nThe box comes off when price wicks ENTIRELY through it, never on a touch. A tap that reverses leaves it up, which matters here: a gap getting tapped and holding is exactly why the level underneath never got retested.')
pcTxtCol = input.color(color.new(color.white, 30), 'Label', group = GRP_PCF, active = pcOn, tooltip = 'Colour of the small timeframe tag inside each box. It names the timeframe the gap was found on, which is the CISD\'s pair LTF - so a 5m CISD tags its gaps "5m" even when you are looking at a 1m chart.\n\nSet it fully transparent if you want the boxes bare.')

string GRP_LRLR = 'LRLR — Low Resistance Liquidity Run'
lrOn    = input.bool(true, 'Show LRLR', group = GRP_LRLR, tooltip = 'A Low Resistance Liquidity Run is a STAIRCASE of unswept swing extremes. In a bearish run price prints lower lows AND lower highs, and every one of those lower highs is a failure swing - a high that failed to take the previous high - left UNSWEPT. Four or more stacked is no longer a single untouched level, it is a ladder of resting liquidity along one clean diagonal. Bullish is the mirror.\n\nThe line does NOT connect the points. It is fitted through them for the true angle, then floated outward so it hovers clear of the structure. A wick poking through between rungs is normal and does not invalidate anything - the sequence of swings is what qualifies a run, not whether every bar respects the drawn line.\n\nBy default these only appear when a SETUP IS LIVE. See Draw when, below.')
lrGate  = input.string('Setup draw only', 'Draw when', options = ['Setup draw only', 'Any live setup', 'Always'], group = GRP_LRLR, active = lrOn, tooltip = 'SETUP DRAW ONLY (default) - a run only appears when a CISD has CONFIRMED and the run sits on the side that setup is DRAWING TOWARD. A confirmed bull setup shows you the ladder ABOVE; a confirmed bear setup shows the ladder BELOW. That is the question the tool is answering: my target is up there, is the road to it clean?\n\nANY LIVE SETUP - both directions draw whenever any setup is confirmed. Use it if you want to see what is on BOTH sides before committing.\n\nALWAYS - ungated. Every run draws all the time. Expect a busy chart; this is the standalone behaviour.\n\nNote the engine never stops running. Runs are detected continuously and only their VISIBILITY is gated, so a staircase that formed well before your setup confirmed is already on the chart the moment it does.')
lrBull  = input.color(#000000, 'Bullish / Bearish ', inline = 'lrc', group = GRP_LRLR, active = lrOn)
lrBear  = input.color(#000000, '', inline = 'lrc', group = GRP_LRLR, active = lrOn, tooltip = 'A BULLISH run is higher highs + higher lows and its line hovers BELOW the higher lows, marking unswept sellside under price. A BEARISH run is the mirror, hovering ABOVE the lower highs, marking unswept buyside above.\n\nSo under "Setup draw only" a confirmed BULL setup surfaces the runs ABOVE price - which will be the bearish-coloured ones. That is not a mix-up: an LRLR is liquidity, a setup is an attempt to RAID it, and a bull setup is reaching for the ladder above. The colour tells you how the ladder was built; the side tells you whether your setup is aiming at it.')
lrWidth = input.int(1, 'Width', minval = 1, maxval = 4, inline = 'lrs', group = GRP_LRLR, active = lrOn)
lrStyle = input.string('⎯⎯⎯', '', options = ['⎯⎯⎯', '----', '····'], inline = 'lrs', group = GRP_LRLR, active = lrOn)
lrLblBull = input.string('Above', 'Label  Bull / Bear', options = ['Above', 'Below'], inline = 'lrl', group = GRP_LRLR, active = lrOn)
lrLblBear = input.string('Below', '', options = ['Above', 'Below'], inline = 'lrl', group = GRP_LRLR, active = lrOn, tooltip = 'Which side of the LINE the tag hangs on, set separately per direction.\n\nThe default - bull Above, bear Below - puts both tags on the side facing the candles: a bearish line hovers ABOVE price so its tag drops toward the action, and a bullish line hovers BELOW price so its tag lifts toward it. Flip either one if you would rather the tag sit on the outside.')
lrLblH    = input.string('Right', 'Tag anchor', options = ['Left', 'Center', 'Right'], inline = 'lrl2', group = GRP_LRLR, active = lrOn, tooltip = 'Which END of the line the tag sits at.\n\nRight is the default and stays out of the price action on both sides. Center is closer to how you would tag it drawing by hand. Left anchors it at the run\'s ORIGIN - useful when you want your eye pulled to the far rung, since that is the one price has to clear to spend the whole ladder.')

string GRP_LIQ = 'Nearby Liquidity'
lqBMajOn = input.bool(true, 'Buyside Major ', inline = 'lq1', group = GRP_LIQ)
lqBMajCol = input.color(color.new(#000000, 0), '', inline = 'lq1', group = GRP_LIQ)
lqBMajSty = input.string('····', '', options = ['⎯⎯⎯', '----', '····'], inline = 'lq1', group = GRP_LIQ)
lqBMinOn = input.bool(true, 'Buyside Minor ', inline = 'lq2', group = GRP_LIQ)
lqBMinCol = input.color(color.new(#000000, 0), '', inline = 'lq2', group = GRP_LIQ)
lqBMinSty = input.string('····', '', options = ['⎯⎯⎯', '----', '····'], inline = 'lq2', group = GRP_LIQ)
lqSMajOn = input.bool(true, 'Sellside Major', inline = 'lq3', group = GRP_LIQ)
lqSMajCol = input.color(color.new(#000000, 0), '', inline = 'lq3', group = GRP_LIQ)
lqSMajSty = input.string('····', '', options = ['⎯⎯⎯', '----', '····'], inline = 'lq3', group = GRP_LIQ)
lqSMinOn = input.bool(true, 'Sellside Minor', inline = 'lq4', group = GRP_LIQ)
lqSMinCol = input.color(color.new(#000000, 0), '', inline = 'lq4', group = GRP_LIQ)
lqSMinSty = input.string('····', '', options = ['⎯⎯⎯', '----', '····'], inline = 'lq4', group = GRP_LIQ, tooltip = 'The nearest untaken MAJOR and MINOR liquidity pool on each side of price, each with its own toggle, color and line type. Backend-managed with no timeframe or length settings: two swing engines (minor and major) pool near-equal swing points with an ATR-scaled tolerance, so the read adapts to any symbol and chart TF on its own. A level is deleted the moment price trades through it. Buyside = above price, sellside = below.')
// v4.54: Ryan, 25/9/2026 - "we can have multiple minors". A fresh swing low inside the range was
//   hidden because only the single nearest minor drew. Minors stay INSIDE the major (hierarchy kept).
lqMinN = input.int(5, 'Minors per side', minval = 1, maxval = 5, group = GRP_LIQ, tooltip = 'How many MINOR pools draw on each side, nearest to price first. They only draw between price and that side\'s Major level - a minor never draws past the major.\n\nWhy you may see a clear swing with no Major tag: a swing only grades Major once 40 bars have printed on each side of it. A fresh swing low shows as a minor until then, and upgrades on its own if it survives.')
// v4.54: Ryan, 25/9/2026 - name a pool that sits on a key level instead of hiding it or drawing the
//   key levels themselves. Readout only: never changes which pools draw.
lqKeyTagOn = input.bool(true, 'Tag key levels', group = GRP_LIQ, tooltip = 'When a drawn pool sits on the previous day, week or month high or low, its label says so: SSL · PDL, BSL · PWH, and so on. If it lines up with more than one, the higher timeframe wins (PML over PWL over PDL).\n\nThis only names the pool. It does not draw the key levels themselves and does not change which pools show.\n\nPrevious day = the exchange daily candle. On CME futures that runs 6pm to 5pm New York time, so a 4am low belongs to that day\'s candle.')
lqLblSz = input.string('Tiny', 'Label Size', options = ['Tiny', 'Small', 'Medium', 'Large'], group = GRP_LIQ)
lqThick = input.bool(true, 'Thicken Stacked Pools', group = GRP_LIQ, tooltip = 'ON = a pooled level holding 2+ equal swings draws one width thicker. ON is the default. OFF = every level draws at width 1.')
lqShort = input.bool(true, 'Shorthand Labels', group = GRP_LIQ, tooltip = 'ON = flat tags: BSL / SSL, major and minor alike. OFF = Major BSL / Minor BSL / Major SSL / Minor SSL.')

// - - - - - - - - -
// SMT Divergence Settings - C1/C2 SMT on the HTF mini candles.
string g_smt = 'SMT'
// Adapted from "cephxs + fadi / HTF PSP" by cephxs and fstarcapital (MPL 2.0): asset selection inputs
// and the manual-asset reordering below.
string g_assetSelection = 'Asset Selection (For All Divergences)'

in_smt_on = input.bool(true, 'Show SMT', group = g_smt, tooltip = 'C1/C2 SMT marks between adjacent mini candles on each HTF slot, at that slot\'s timeframe.')
in_smt_bull = input.color(color.new(#000000, 0), 'Bull / Bear', inline = 'smtline', group = g_smt)
in_smt_bear = input.color(color.new(#000000, 0), '', inline = 'smtline', group = g_smt, tooltip = 'SMT line + tag colors (bull = at lows / bear = at highs).')
in_smt_style = input.string('⎯⎯⎯', 'Line type', options = ['⎯⎯⎯', '----', '····'], group = g_smt)
in_smt_lbl = input.string('Center', 'Label Position', options = ['Left', 'Center', 'Right'], group = g_smt, active = in_smt_on, tooltip = 'Where the correlate symbol sits on the SMT mark.\n\nLEFT pins it to the first candle of the divergence, RIGHT to the second, CENTER splits them (the original behaviour).\n\nAn SMT line runs diagonally, so the label follows the line vertically as well as horizontally - pick an end and the tag stays glued to that end, not to the line\'s overall high or low. It always sits above a bear mark and below a bull one, clear of the line either way.')

string assetPreset = input.string('Auto', '🔥 Correlation Preset', options = ['Auto', 'Manual'], group = g_assetSelection, tooltip = 'Asset correlation preset. Auto covers the index futures complex: NQ, ES, YM, RTY and their micros. On ANY other symbol Auto finds no correlate and SMT simply does not draw - switch to Manual and enter at least two symbols, one of which must match the chart.')
string in_smt_a1 = input.symbol('', 'Manual Asset 1', group = g_assetSelection, active = assetPreset == 'Manual')
string in_smt_a2 = input.symbol('', 'Manual Asset 2', group = g_assetSelection, active = assetPreset == 'Manual')
string in_smt_a3 = input.symbol('', 'Manual Asset 3', group = g_assetSelection, active = assetPreset == 'Manual')
bool in_smt_inv3 = input.bool(false, 'Invert Asset 3', group = g_assetSelection, tooltip = 'Invert bullish/bearish for Asset 3 (e.g., for inverse correlations like DXY). This flag follows Asset 3 when reordered.', active = assetPreset == 'Manual')

// Resolved correlated symbols for the SMT streams (assigned once at barstate.isfirst)
var string g_corr2 = ''
var string g_corr3 = ''
var bool g_inv2 = false
var bool g_inv3 = false

// - - - - - - - - -
// Helper functions

f_lstyle(string s) =>
    s == '----' ? line.style_dashed : s == '····' ? line.style_dotted : line.style_solid

// Label size from a Tiny/Small/Medium/Large picker.
// v4.44: was f_lqSize(), which read lqLblSz straight off the global. Same output, but MM
// IFVG carries the identical mapper under this name and passes the input in. One name and
// one shape across both PRO files, so a fix here is a fix there.
// v4.45: moved up here beside f_lstyle, where the generic mappers belong - the trade plan
// calls it from f_buildSD, which sits far above where it used to live, and a Pine global
// has to be declared before the function that reads it.
f_szName(string s) =>
    s == 'Large' ? size.large : s == 'Medium' ? size.normal : s == 'Small' ? size.small : size.tiny

f_tfName(string tf) =>
    int sec = timeframe.in_seconds(tf)
    sec < 60 ? str.tostring(sec) + 's' : sec < 3600 ? str.tostring(sec / 60) + 'm' : sec < 86400 ? str.tostring(sec / 3600) + 'H' : tf

f_countdown(simple string tf) =>
    string out = 'n/a'
    if barstate.isrealtime
        int rem = math.max(0, int((time_close(tf) - timenow) / 1000))
        int dd = int(rem / 86400)
        int hh = int(rem % 86400 / 3600)
        int mm = int(rem % 3600 / 60)
        int ss = rem % 60
        out := str.tostring(ss, '00')
        if mm > 0 or hh > 0 or dd > 0
            out := str.tostring(mm, '00') + ':' + out
        if hh > 0 or dd > 0
            out := str.tostring(hh, '00') + ':' + out
        if dd > 0
            out := str.tostring(dd) + 'D ' + out
    out

// A slot timeframe is drawable when it is above the chart TF and lines up with it
// (an exact bar multiple), or is a day-or-larger period above the chart TF.
f_tfOk(simple string tf) =>
    int cs = timeframe.in_seconds()
    int hs = timeframe.in_seconds(tf)
    hs >= timeframe.in_seconds('1D') ? hs > cs : cs < hs and hs % cs == 0

// True on the first chart bar of a new tf period (with the custom daily open variants).
f_newPeriod(simple string tf) =>
    bool roll = nz(ta.change(time(tf)), 0) != 0
    if in_customd_on and tf == '1D'
        if in_customd == 'Midnight'
            roll := dayofweek(time, 'America/New_York') != dayofweek(nz(time[1], time), 'America/New_York')
        else
            string win = in_customd == '8:30' ? '0830-0831:123456' : '0930-0931:123456'
            int wt = time(timeframe.period, win, 'America/New_York')
            roll := not na(wt) and na(wt[1])
    roll

// Tag text for a freshly opened candle: day letter for 1D, minute/hour of open for
// intraday, month for 1M-and-up.
f_candleTag(simple string tf, int oT) =>
    int hs = timeframe.in_seconds(tf)
    string out = ''
    if tf == '1D'
        int dw = dayofweek(time_tradingday, 'America/New_York')
        out := dw == 1 ? 'M' : dw == 2 ? 'T' : dw == 3 ? 'W' : dw == 4 ? 'T' : dw == 5 ? 'F' : 'S'
    else if hs >= 2419200
        out := str.format_time(oT, 'M', 'America/New_York')
    else if hs >= 3600
        out := str.format_time(oT, 'H', 'America/New_York')
    else
        out := str.format_time(oT, 'm', 'America/New_York')
    out

isValidSymbol(string sym) =>
    not na(sym) and str.length(sym) > 0

// Strip the exchange prefix from a ticker id ('CME_MINI:ES1!' -> 'ES1!')
f_symName(string s) =>
    array<string> parts = str.split(s, ':')
    parts.size() > 0 ? parts.get(parts.size() - 1) : s

// Does this symbol input refer to the chart's own instrument?
f_isChart(string sym) =>
    str.length(sym) > 0 and (str.contains(syminfo.tickerid, sym) or str.contains(sym, syminfo.ticker))

// - - - - - - - - -
// Correlated-asset resolution -> four plain globals (g_corr2/g_corr3 + invert flags).
// Auto covers the index-futures trio (NQ/ES/YM + RTY, micros included); any other
// chart returns no correlate and SMT simply doesn't draw. Manual mode reorders the
// three symbol inputs so the chart is always primary; Asset 3's invert flag follows
// Asset 3 to wherever it lands (and flips onto the others when Asset 3 IS the chart).
if barstate.isfirst
    if assetPreset == 'Manual'
        bool m1 = f_isChart(in_smt_a1)
        bool m2 = f_isChart(in_smt_a2)
        bool m3 = f_isChart(in_smt_a3)
        int filled = (str.length(in_smt_a1) > 0 ? 1 : 0) + (str.length(in_smt_a2) > 0 ? 1 : 0) + (str.length(in_smt_a3) > 0 ? 1 : 0)
        if filled >= 2 and (m1 or m2 or m3)
            if m1
                g_corr2 := str.length(in_smt_a2) > 0 ? in_smt_a2 : in_smt_a3
                g_corr3 := str.length(in_smt_a2) > 0 and str.length(in_smt_a3) > 0 ? in_smt_a3 : ''
                g_inv2 := str.length(in_smt_a2) == 0 and in_smt_inv3
                g_inv3 := str.length(in_smt_a2) > 0 and in_smt_inv3
            else if m2
                g_corr2 := str.length(in_smt_a1) > 0 ? in_smt_a1 : in_smt_a3
                g_corr3 := str.length(in_smt_a1) > 0 and str.length(in_smt_a3) > 0 ? in_smt_a3 : ''
                g_inv2 := str.length(in_smt_a1) == 0 and in_smt_inv3
                g_inv3 := str.length(in_smt_a1) > 0 and in_smt_inv3
            else
                g_corr2 := str.length(in_smt_a1) > 0 ? in_smt_a1 : in_smt_a2
                g_corr3 := str.length(in_smt_a1) > 0 and str.length(in_smt_a2) > 0 ? in_smt_a2 : ''
                g_inv2 := in_smt_inv3
                g_inv3 := in_smt_inv3
    else
        string r = syminfo.root
        if r == 'NQ' or r == 'MNQ'
            g_corr2 := 'CME_MINI:ES1!'
            g_corr3 := 'CBOT_MINI:YM1!'
        else if r == 'ES' or r == 'MES'
            g_corr2 := 'CME_MINI:NQ1!'
            g_corr3 := 'CBOT_MINI:YM1!'
        else if r == 'YM' or r == 'MYM'
            g_corr2 := 'CME_MINI:NQ1!'
            g_corr3 := 'CME_MINI:ES1!'
        else if r == 'RTY' or r == 'M2K'
            g_corr2 := 'CME_MINI:ES1!'
            g_corr3 := 'CME_MINI:NQ1!'

// - - - - - - - - -
// CISD Fractal Pair Settings
string g_cisd = 'CISD: Timeframe Pair'

// Ryan's canonical fractal pairs (C2 HTF -> CISD LTF), the single source of truth:
// 1M -> 1D | 1W -> 4h | 1D -> 1h | 4h -> 15m | 1h -> 5m | 30m -> 3m | 15m -> 1m | 5m -> 15s
// (implemented in reverse by f_c2ForLtf below: given a CISD LTF, return its C2 HTF)
f_c2ForLtf(string ltfTf) =>
    int s = timeframe.in_seconds(ltfTf)
    switch
        s >= timeframe.in_seconds('1D') => '1M'
        s >= 14400 => '1W'
        s >= 3600 => '1D'
        s >= 900 => '240'
        s >= 300 => '60'
        s >= 180 => '30'
        s >= 60 => '15'
        => '5'

// v4.21 - the INVERSE of the pair table: given a C2 HTF, return the CISD LTF it pairs
// with. Only the CISD table uses this, because that table is picked in C2 terms ("1h")
// rather than CISD terms ("5m") - which is how the setup is actually spoken about.
f_ltfForC2(simple string htfTf) =>
    bool isM = htfTf == 'M' or htfTf == '1M'
    bool isW = htfTf == 'W' or htfTf == '1W'
    bool isD = htfTf == 'D' or htfTf == '1D'
    int s = isM or isW or isD ? 0 : timeframe.in_seconds(htfTf)
    isM ? '1D' : isW ? '240' : isD ? '60' :
     s >= 14400 ? '15' : s >= 3600 ? '5' : s >= 1800 ? '3' : '1'

// CISD-table C2 picker -> timeframe string.
f_ctTf(simple string sel) =>
    sel == '15m' ? '15' : sel == '30m' ? '30' : sel == '1h' ? '60' : sel == '4h' ? '240' : '1D'

// Map a CISD selector to the timeframe it hunts/draws on. Chart and Off both resolve to
// the chart TF (Off is gated separately by cisd_en_*, so it never draws).
f_selLtf(string sel) =>
    sel == '1m' ? '1' : sel == '3m' ? '3' : sel == '5m' ? '5' : sel == '15m' ? '15' : sel == '1h' ? '60' : sel == '4h' ? '240' : sel == '1D' ? '1D' : timeframe.period

// Three independent CISD selectors. Pick which timeframe of CISD each shows (or Off);
// the C2 HTF is auto-derived from the pair table (so "3m" = 30m C2 -> 3m CISD).
string cisd_sel_1 = input.string('15m', 'CISD 1', options = ['Chart', '1m', '3m', '5m', '15m', '1h', '4h', '1D', 'Off'], group = g_cisd, tooltip = 'A CISD draws only when your chart TF is at or below its timeframe - from a 15m chart, a 5m or 1m CISD stays hidden until you drop the chart TF. Two selectors set to the same TF: the later one is disabled instead of drawing duplicates.')
string cisd_sel_2 = input.string('5m', 'CISD 2', options = ['Chart', '1m', '3m', '5m', '15m', '1h', '4h', '1D', 'Off'], group = g_cisd)
string cisd_sel_3 = input.string('1m', 'CISD 3', options = ['Chart', '1m', '3m', '5m', '15m', '1h', '4h', '1D', 'Off'], group = g_cisd, tooltip = 'Each selector draws one timeframe of CISD, or Off. C2 auto-pairs from the pair table: 1m<-15m, 3m<-30m, 5m<-1h, 15m<-4h, 1h<-1D, 4h<-1W, 1D<-1M. Chart = the current chart timeframe. A CISD only draws when the chart TF is at or below the selected CISD TF.')
string cisd_ltf_a = f_selLtf(cisd_sel_1)
string cisd_ltf_b = f_selLtf(cisd_sel_2)
string cisd_ltf_c = f_selLtf(cisd_sel_3)
string cisd_c2_a = f_c2ForLtf(cisd_ltf_a)
string cisd_c2_b = f_c2ForLtf(cisd_ltf_b)
string cisd_c2_c = f_c2ForLtf(cisd_ltf_c)
// Dedup: two selectors resolving to the SAME CISD timeframe would run identical hunts
// and stack identical lines on top of each other - later duplicates are disabled.
bool cisd_en_a = cisd_sel_1 != 'Off'
bool cisd_en_b = cisd_sel_2 != 'Off' and not (cisd_en_a and cisd_ltf_b == cisd_ltf_a)
bool cisd_en_c = cisd_sel_3 != 'Off' and not (cisd_en_a and cisd_ltf_c == cisd_ltf_a) and not (cisd_en_b and cisd_ltf_c == cisd_ltf_b)
in_cisd_bull = input.color(color.new(#00adef, 0), 'Bull    ', inline = 'cisdcol', group = g_cisd)
in_cisd_bear = input.color(color.new(#db5755, 0), 'Bear', inline = 'cisdcol', group = g_cisd, tooltip = 'CISD line and tag colors. Use the color picker opacity slider to control transparency per side.')
in_cisd_style = input.string('----', 'Line    ', options = ['⎯⎯⎯', '----', '····'], inline = 'cisdstyle', group = g_cisd)
in_cisd_width = input.int(1, '', minval = 1, maxval = 5, inline = 'cisdstyle', group = g_cisd, tooltip = 'Line style and thickness.')
// v4.19: a CISD level is not real until price CLOSES through it. Before that the engine is
// only HUNTING a level it has captured - so by default a hunt now draws nothing at all.
// Potential CISD is the opt-in preview of that hunt, in its own line style, upgraded to the
// confirmed style/width the moment it confirms.
in_pot_on = input.bool(false, 'Potential CISD', inline = 'cisdpot', group = g_cisd)
in_pot_style = input.string('----', '', options = ['⎯⎯⎯', '----', '····'], inline = 'cisdpot', group = g_cisd, active = in_pot_on, tooltip = 'A CISD is only CONFIRMED once an LTF candle CLOSES through the captured level. Until that close the engine is just hunting.\n\nOFF (default) = nothing draws while hunting. The line, its tag and its projections all appear together, at the confirmation.\n\nON = the level you are hunting draws early in the style picked here (dashed by default), so you can see it coming. It is a POTENTIAL level, not a signal - it can still die. The instant it confirms it turns into your normal CISD line (the style and thickness set above), so solid = confirmed, dashed = not yet.\n\nStd-Dev Projections never draw on a potential line either way - they arrive with the confirmation.')
// v4.53: both directions confirmed on one fractal pair is CORRECT behaviour, not a fault -
// see the banner for how one candle ends up being two setups. Flag only says so out loud and
// changes nothing. Void is the variation that acts on it, and it is the only part of this
// feature that touches the model.
string in_opp_mode = input.string('Off', 'Opposing setups', options = ['Off', 'Flag', 'Void'], inline = 'cisdopp', group = g_cisd)
in_opp_col = input.color(color.new(#f5a800, 0), '', inline = 'cisdopp', group = g_cisd, active = in_opp_mode != 'Off', tooltip = 'What to do with a confirmed CISD that printed while an OPPOSITE confirmed setup is still live on the same timeframe pair.\n\nHOW THAT HAPPENS. A setup\'s own C3 can sweep the other side and close back inside, which makes it a C2 in the opposite direction. One candle, two roles. The first setup does not fail when that happens - its C3 never took its C2 extreme, and once C3 is over a confirmed setup cannot fail at all. So both are valid and both stay drawn.\n\nWHY YOU WANT THE TAG. If you are holding the first one, the second confirming at your objective is the most important thing on the screen - and the two setups are separate state that know nothing about each other. This is the only place the chart says they are pointing opposite ways.\n\nTHE WINDOW. The opposing C2 has to BE the first setup\'s own C3 or C4 candle - the candle that took its extreme and closed back inside. Anything later is not a conflict, it is just the next setup, and it is not tagged. Same C3 to C4 lifetime the CISD table Seq row runs on.\n\nWHAT IT MARKS. One small tag at the start of the CISD line that JUST confirmed, plus one alert naming which candle it landed on. Fires once per setup. The older setup is left exactly as it is, on purpose: a level you may be trading should not change appearance because something else printed.\n\nIT CHANGES NOTHING ELSE. Not the setups, not the levels, not the targets, not the invalidation. Turn it off and every other behaviour is identical.\n\nSAME PAIR ONLY. A 1m bull against a live 15m bear is a different question and a far noisier one; this answers the one your own chart is already asking.\n\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nTHE THREE MODES\n\nOFF (default) - nothing. Opposing setups draw and behave exactly as they always have.\n\nFLAG - the setup still prints in full. You get one amber OPP tag at the start of its line and one alert. NOTHING about the model changes: same levels, same targets, same invalidation. This is a marking layer.\n\nVOID - the opposing setup is REMOVED. Its line, its CISD tag and its Std-Dev ladder all go, and a muted \'OPP \u00d7\' is left at the level so it did not silently vanish. THIS CHANGES BEHAVIOUR - it is an in-house variation like Mauler Mode, not a display option.\n\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nBEFORE YOU USE VOID\n\nThe standard published rules do NOT void these. It names \'Opposing Setup Formation\' as one of two tradable invalidation scenarios and says you wait for the opposing structure to form before entering - it is traded, not discarded. There is no published C5 rule and no published opposite-direction void anywhere in the standard published rules.\n\nSo Void is a Maulers variation, not the standard model. Use it if you want one direction at a time on a pair and would rather not see the other one at all. If you are unsure, run Flag.\n\nVOID FIRES A SECOND ALERT. The setup CONFIRMED before this layer can see it, so the confirm alert has already gone out. The void therefore sends its own message rather than leaving that first one dangling with nothing on the chart to match it.')
bool in_opp_on = in_opp_mode != 'Off'
bool in_opp_void = in_opp_mode == 'Void'
in_zone_on = input.bool(true, 'Stacked CISD zone', group = g_cisd, tooltip = 'When two or three timeframe pairs print their C2 on the SAME close (e.g. the 15m and 1h candles sealing together on the hour) and every one of those CISDs CONFIRMS, the space between the confirmed levels fills as one zone. The level lines stay as the zone\'s exact edges; their tags collapse into one combined tag. Until every member confirms, lines behave exactly as normal.')
in_zone_ext = input.string('Confirm', 'Zone extend', options = ['Confirm', 'Current bar', 'Extend right'], group = g_cisd, tooltip = 'How far the stacked zone runs to the right. Confirm = ends where the last member confirmed. Current bar = the right edge follows price, so the zone stays live for retests. Extend right = runs indefinitely.')

// CISD Standard Deviation Projections
in_sd_show = input.bool(false, 'Std-Dev Projections', group = g_cisd, tooltip = 'After a CISD CONFIRMS, project standard deviations of the leg that swept C1 (its swing low to swing high, or high to low) in the delivery direction. 0 = the leg origin wick, 1 = the sweep extreme; the levels below are projected past 0 as your targets.\n\nNothing draws while a setup is only being hunted - the ladder arrives with the confirmation, and a hunt that dies leaves the chart clean.\n\nOne ladder per confirmed CISD per slot, kept in step with Max setups. Inside a stacked zone only ONE ladder is ever on screen: the lowest pair TF first (your nearest targets), handing off to the next higher TF the moment price trades through its deepest level. That handoff latches - a retrace never flips it back. The hidden ladder is intentional, not missing.')
string cisd_sd_levels_in = input.string('1, 2, 2.5, 3, 4', 'Projection Levels', group = g_cisd, active = in_sd_show, tooltip = 'Comma-separated standard-deviation multiples to project past 0 in the delivery direction (e.g. 1, 2, 2.5, 3, 4). The 0 and 1 leg anchors always draw when projections are on.')
in_sd_color = input.color(color.new(#787b86, 0), 'SD Color', inline = 'sdstyle', group = g_cisd, active = in_sd_show)
in_sd_style = input.string('⎯⎯⎯', '', options = ['⎯⎯⎯', '----', '····'], inline = 'sdstyle', group = g_cisd, active = in_sd_show, tooltip = 'Color and line type for the standard-deviation ticks and labels.')
in_sd_labels = input.string('Right', 'SD Labels', options = ['Off', 'Left', 'Right'], group = g_cisd, active = in_sd_show, tooltip = 'Number labels off, on the left end (at the origin swing), or on the right end (extended side).')
in_sd_extend = input.int(5, 'SD Extend (bars)', minval = 1, maxval = 500, group = g_cisd, active = in_sd_show, tooltip = 'How many chart bars each level segment runs to the right of where the CISD line begins. Each level is a short segment anchored at the CISD start and extending right by this amount.')

// ═══ Trade Plan (v4.45) — a READOUT, and only that ═══
// It draws where a stop would sit and how many contracts that stop buys you. It does not
// participate in the setup's life. Nothing here can fail, kill, colour, age or delete a
// setup, and price trading through a drawn stop is a non-event to this script. See the
// banner for why that is a deliberate difference from MM IFVG rather than an omission.
string GRP_PLAN = 'Trade Plan'

pl_on = input.bool(true, 'Show Stop + Size', group = GRP_PLAN, tooltip = 'Draws a stop level on every CONFIRMED setup, with an optional contract count.\n\nREAD IT AS A READOUT. It changes nothing about the setup. Price trading through this line does not fail the setup, does not delete anything, does not recolour anything and fires no alert. A confirmed CISD still dies from exactly one thing - its C2 extreme taken while still inside C3 - and ages out on the setup budget, same as it always has.\n\nThe line runs from where the CISD line begins and ends flush with the SD ladder, and it leaves when its setup does.')
pl_mode = input.string('C2 Extreme', 'SL Placement', options = ['C2 Extreme', 'CISD Level', 'CISD + Buffer'], group = GRP_PLAN, active = pl_on, tooltip = 'The three placements people actually use on this model.\n\nC2 EXTREME - the C2 low on a bull setup, the C2 high on a bear. This is the same level the FC2 invalidation watches, so what you see drawn and what the engine treats as wrong are the same number. Widest of the three, smallest size.\n\nCISD LEVEL - the CISD line itself, no buffer. Note it lands on top of the CISD line already on your chart, so you will see it in the size rather than as a separate level.\n\nCISD + BUFFER - the CISD level plus the buffer below, set beyond the level in the losing direction. Tightest of the three, largest size.')
pl_buf = input.int(4, 'SL Buffer (ticks)', minval = 0, maxval = 500, group = GRP_PLAN, active = pl_on and pl_mode == 'CISD + Buffer', tooltip = 'Ticks beyond the CISD level, in the direction that would be wrong - below it on a bull setup, above it on a bear. Applies to the CISD + Buffer placement only.')
pl_col = input.color(color.new(#ff5252, 0), 'Stop line', inline = 'plln', group = GRP_PLAN, active = pl_on)
pl_sty = input.string('----', '', options = ['⎯⎯⎯', '----', '····'], inline = 'plln', group = GRP_PLAN, active = pl_on)
pl_wid = input.int(1, '', minval = 1, maxval = 4, inline = 'plln', group = GRP_PLAN, active = pl_on, tooltip = 'Colour, line type and width for the stop level.')
pl_lbl = input.bool(true, 'Show Size Label', group = GRP_PLAN, active = pl_on, tooltip = 'The contract count and dollar risk, tucked at the right end of the stop line. Reads as "3 minis · $498" - three mini contracts, four hundred and ninety eight dollars at risk if the stop pays out.\n\nIf ONE mini would risk more than your budget, it sizes in micros instead and says "7 micros · $472". See Micros per Mini below.\n\nOff leaves the stop level drawn with no text on it.')
pl_risk = input.float(500, 'Risk Budget ($)', minval = 1, step = 100, group = GRP_PLAN, active = pl_on and pl_lbl, tooltip = 'What you are willing to lose on one trade. Contracts = this divided by the per-contract risk, rounded DOWN. If even ONE mini would exceed it, the label sizes in MICROS instead of quoting a position past your limit.\n\nPer-contract risk is measured from the CONFIRMING CLOSE - the close of the candle that closed through the CISD - because that is the entry this model actually takes. The retest entry is not modelled here; it does not always come.')
pl_micro = input.int(10, 'Micros per Mini', minval = 1, maxval = 100, group = GRP_PLAN, active = pl_on and pl_lbl, tooltip = 'How many micro contracts equal one mini. 10 is correct for NQ/MNQ, ES/MES, YM/MYM, GC/MGC and CL/MCL.\n\nWhen ONE mini would risk more than the Risk Budget, the size label switches to micros and says so. Set this to 1 to switch that off and always size in minis.\n\nNot every contract has a clean tenth-sized micro, so check yours before trusting the number on an instrument outside that list.')
pl_txt = input.color(color.new(#ff5252, 0), 'Label colour / size', inline = 'pllbl', group = GRP_PLAN, active = pl_on and pl_lbl)
pl_lsz = input.string('Tiny', '', options = ['Tiny', 'Small', 'Medium', 'Large'], inline = 'pllbl', group = GRP_PLAN, active = pl_on and pl_lbl, tooltip = 'Colour and text size for the size label.')

// ═══ CISD Table (v4.21) — a READOUT, deliberately independent of what draws ═══
// Most people run one CISD on the chart to keep it clean. That's a drawing choice, and
// it shouldn't decide what you're allowed to KNOW. This table runs the same engine
// read-only on its own two timeframes and draws nothing, so the chart stays as sparse
// as you want it while the table still shows the full picture.
string g_ctbl = 'CISD Table'

in_ct_on = input.bool(true, 'Show CISD table', group = g_ctbl, tooltip = 'A readout of the setups, one column per C2 timeframe. Rows: status, direction, CISD price, Confirmed or Unconfirmed, and which candle of the sequence it is in.\n\nSTATUS: Yes = a live C2 on that pair. No = none right now. FC2 = it confirmed, then failed inside C3. Fail = it died while still hunting. chart TF = your chart is above that pair\'s CISD timeframe, so it cannot be read here.\n\nIt is completely separate from the CISD selectors above. Those decide what DRAWS on your chart; this decides what you can read. Run one CISD on the chart and still watch two here.')
in_ct_pos = input.string('Top Right', 'Position', options = ['Top Right', 'Top Left', 'Bottom Right', 'Bottom Left', 'Middle Right'], inline = 'ctpos', group = g_ctbl, active = in_ct_on)
in_ct_size = input.string(size.normal, '', [size.tiny, size.small, size.normal, size.large], inline = 'ctpos', group = g_ctbl, active = in_ct_on)

in_ct_r1 = input.string('1h', 'Column 1  C2 timeframe', options = ['Off', '15m', '30m', '1h', '4h', '1D'], group = g_ctbl, active = in_ct_on, tooltip = 'Pick the C2 timeframe you think in - the CISD it reads is derived from your pair table automatically:\n\n1h C2 reads the 5m CISD\n4h C2 reads the 15m CISD\n1D C2 reads the 1h CISD\n30m C2 reads the 3m CISD\n15m C2 reads the 1m CISD')
in_ct_r2 = input.string('4h', 'Column 2  C2 timeframe', options = ['Off', '15m', '30m', '1h', '4h', '1D'], group = g_ctbl, active = in_ct_on, tooltip = 'Second column, same idea. Set any column to Off and the ones after it close the gap — the table is always as narrow as the number of timeframes you actually picked.\n\nNote a column can only compute when your CHART timeframe is at or below the CISD it reads — a 1h column reads the 5m, so from a 15m chart there is no 5m data to read and it will say "chart TF". That is a data limit, not a fault.')
in_ct_r3 = input.string('1D', 'Column 3  C2 timeframe', options = ['Off', '15m', '30m', '1h', '4h', '1D'], group = g_ctbl, active = in_ct_on, tooltip = 'Optional third timeframe, 1D by default. Set it to Off for a two-column table.')

in_ct_hdrOn = input.bool(true, 'Field labels', group = g_ctbl, active = in_ct_on, tooltip = 'The left-hand column naming each row — C2 conf / Dir / CISD / Seq. Turn it off for a bare grid once you know the rows by heart. The hover notes live on those labels, so leave it on while you are still learning it.')
// v4.54: Daily Bias row (Ryan, 25/9/2026). Top row of the table, above every timeframe column.
in_ct_bias = input.bool(true, 'Daily Bias row', group = g_ctbl, active = in_ct_on, tooltip = 'Top row of the table. Reads yesterday\'s daily candle against the day before it (its PDH and PDL).\n\nBULLISH\n  • closed ABOVE PDH, or\n  • traded below PDL and closed back INSIDE the range\n\nBEARISH\n  • closed BELOW PDL, or\n  • traded above PDH and closed back INSIDE the range\n\nNEUTRAL\n  • took neither side (inside day), or\n  • took BOTH sides and closed inside\n\nHover the value to see which rule fired. The day is the exchange daily candle: 6pm to 5pm New York time on CME futures.')

in_ct_bg = input.color(color.new(#0d0d0d, 12), 'Background / frame / grid', inline = 'ctc1', group = g_ctbl, active = in_ct_on)
in_ct_frame = input.color(color.new(#f5a800, 40), '', inline = 'ctc1', group = g_ctbl, active = in_ct_on)
in_ct_border = input.color(color.new(#333333, 40), '', inline = 'ctc1', group = g_ctbl, active = in_ct_on, tooltip = 'Cell background, the outer frame, and the inner grid lines. Set the frame to fully transparent for a borderless readout.')

in_ct_hdrCol = input.color(color.new(#9e9e9e, 0), 'Titles / values', inline = 'ctc2', group = g_ctbl, active = in_ct_on)
in_ct_txt = input.color(color.new(#e8e8e8, 0), '', inline = 'ctc2', group = g_ctbl, active = in_ct_on, tooltip = 'Column-title colour, then the colour of the values underneath them.')

in_ct_bull = input.color(color.new(#00e676, 0), 'Bull / Bear / muted', inline = 'ctc3', group = g_ctbl, active = in_ct_on)
in_ct_bear = input.color(color.new(#ff5252, 0), '', inline = 'ctc3', group = g_ctbl, active = in_ct_on)
in_ct_mute = input.color(color.new(#787b86, 0), '', inline = 'ctc3', group = g_ctbl, active = in_ct_on, tooltip = 'Direction colours, then the muted colour used for everything inactive — em-dashes, "No", "chart TF", and columns set to Off.\n\nA live column takes its direction colour on both the "Yes" and the Dir cell, so the two always agree at a glance. The muted colour also marks a CISD that was killed before it could confirm.')

// Parse the projection-levels string once (inputs are static per run)
var array<float> sd_levels_arr = array.new<float>()
if barstate.isfirst
    string sd_cleaned = str.replace_all(cisd_sd_levels_in, ' ', '')
    array<string> sd_parts = str.split(sd_cleaned, ',')
    if array.size(sd_parts) > 0
        for i = 0 to array.size(sd_parts) - 1
            float sd_v = str.tonumber(array.get(sd_parts, i))
            if not na(sd_v)
                array.push(sd_levels_arr, sd_v)

// Index of the DEEPEST projection level (largest multiple) - the handoff trigger
// level for stacked-zone SD ladders.
var int sd_deep_idx = 0
if barstate.isfirst and array.size(sd_levels_arr) > 1
    for i = 1 to array.size(sd_levels_arr) - 1
        if array.get(sd_levels_arr, i) > array.get(sd_levels_arr, sd_deep_idx)
            sd_deep_idx := i

// - - - - - - - - -
// Killzone filter (v4.46) - ported from MM IFVG v6.0, inputs and inKz expression
// unchanged. Read the banner for the one thing that IS different: over there the
// killzone consumes the setup, here it only quiets the alert. The engine never sees it.
string GRP_KZ = 'Killzone Filter'
kzOn = input.bool(false, 'Filter Alerts to Killzones', group = GRP_KZ, tooltip = 'ON = an alert only fires when the event lands inside an enabled window (all times ET, DST-aware).\n\nIT ONLY TOUCHES ALERTS. Outside your windows the chart is unchanged - setups still arm, confirm, draw, project their ladders and invalidate on exactly the same rules. You simply do not get pinged for them.\n\nNote this is not what the same-named filter does in MM IFVG. There, a setup outside the windows is consumed and never prints at all. Here it prints and stays silent.')
kz1On = input.bool(true, '', inline = 'kz1', group = GRP_KZ, active = kzOn)
kz1S = input.session('0200-0500', 'London  ', inline = 'kz1', group = GRP_KZ, active = kzOn)
kz2On = input.bool(true, '', inline = 'kz2', group = GRP_KZ, active = kzOn)
kz2S = input.session('0830-1100', 'NY AM   ', inline = 'kz2', group = GRP_KZ, active = kzOn)
kz3On = input.bool(true, '', inline = 'kz3', group = GRP_KZ, active = kzOn)
kz3S = input.session('1330-1600', 'NY PM   ', inline = 'kz3', group = GRP_KZ, active = kzOn, tooltip = 'The three windows, each with its own toggle. Defaults are the classic ICT killzones; edit freely.')

// Evaluated at global scope on every bar so it is already correct whenever f_alert asks.
// time(tf, session, tz) returns na outside the window, which is the whole test.
bool inKz = not kzOn or
     (kz1On and not na(time(timeframe.period, kz1S, 'America/New_York'))) or
     (kz2On and not na(time(timeframe.period, kz2S, 'America/New_York'))) or
     (kz3On and not na(time(timeframe.period, kz3S, 'America/New_York')))

// - - - - - - - - -
// Alerts - alert() events only (no alertcondition), so ONE chart alert set to
// "Any alert() function call" covers every slot and every event type below.
string g_alerts = 'Alerts'
bool alerts_on = input.bool(false, 'Enable alerts', group = g_alerts, tooltip = 'Master switch. With this ON, create ONE TradingView alert on this indicator with condition set to Any alert() function call - it carries every event in your chosen level, for all slots at once.')
// v4.18: display-only hover note. Carries NO logic - it exists purely as a tooltip
// anchor. v4.28: was three separate ⓘ rows. "Why 1m" and "how snapshot works" were the
// same topic - creating the alert - so they merged here, and the model-pairing note moved
// up to the HTF Slots group where pairing actually lives.
bool al_readme = input.bool(false, 'ⓘ  Setting up your alert', group = g_alerts, tooltip = 'WHICH ALERTS YOU GET DEPENDS ON YOUR SLOTS.\nC2 CONFIRMED comes off your HTF candle SLOTS. Everything after it - armed, confirmed, retest, failed, targets - comes off your CISD SELECTORS. A slot with no matching CISD fires the C2 ping and then goes quiet. See "How to set up a model" in the HTF Slots group.\n\nCREATE IT ON A 1m CHART.\nA CISD only runs when your chart is the SAME SIZE OR SMALLER than it:\n\n  • 5m CISD  -> chart 5m or smaller\n  • 15m CISD -> chart 15m or smaller\n  • 1h CISD  -> chart 1h or smaller\n\nSit on 1m and every CISD fires at once. (On a 15m chart your 5m CISD would go silent.) 1m is not special - it is just small enough to run everything.\n\nYou only need 1m at the MOMENT you create the alert. After that, close it and trade on any chart you like.\n\nSETTINGS ARE FROZEN WHEN YOU CREATE IT.\nTradingView snapshots these settings AND the chart timeframe the moment the alert is created, then runs on that copy forever.\n\nSo: set it up how you want, drop to 1m, create ONE alert with the condition set to "Any alert() function call", then put your chart back to whatever you trade on. The alert is unaffected.\n\nTo CHANGE what the alert does you must DELETE it and create a new one. Editing settings on the chart never updates a live alert.')

// v4.28: ONE dropdown replaced nine checkboxes. The old panel was 14 rows and - worse -
// its three highest-frequency events were all ON by default, so the two alerts you
// actually trade got buried. The split is one question: does the event belong to a SETUP?
//   Setups only  - one setup start to finish, C2 -> armed/confirmed -> retest -> FC2,
//                  plus its SD hits and stacked zones.
//   Everything   - + sweep developing, SMT, continuation. Ryan's three: they belong to no
//                  particular setup, they are market context, and they are the loud ones.
// Ryan's call: FC2 is promoted into the default (the one alert that says GET OUT was
// shipping OFF), and Continuation is demoted - it fires to announce that nothing happened,
// so it must never buzz a phone on a default install.
string in_allevel = input.string('Setups only', 'Alert level', options = ['Setups only', 'Everything'], group = g_alerts, active = alerts_on, tooltip = 'SETUPS ONLY (default)\nOne setup, start to finish:\n\n  1. C2 CONFIRMED - a C2 sealed on one of your candle slots. Go look. Carries the extreme that would invalidate it.\n  2. CISD ARMED or CISD CONFIRMED - exactly one of these fires. ARMED means the level was captured but price has not closed through it yet, so it is still hunting. CONFIRMED means it already has, and that message carries the stop reference and every SD target.\n  3. CISD RETEST - a confirmed level is being tapped back into. Usually your entry.\n  4. FC2 - the setup failed inside C3. Stand down.\n  5. SD level hit - a target got tagged.\n  6. Stacked zone - multiple timeframe pairs confirmed into the same zone.\n  7. Opposing setup - a CISD confirmed on the live opposite setup\'s own C3 or C4. Needs Opposing setups set to Flag or Void; Void sends a second message saying the setup was removed.\n\nEVERYTHING\nAdds the three that are not part of any one setup - they are market context:\n\n  • HTF sweep developing - fires INTRABAR, the moment price trades through the prior candle. On every slot. Most never become a C2.\n  • SMT - divergence against your correlated symbols.\n  • Continuation - a candle closed THROUGH the prior high or low. Expansion, not a setup - this one fires to tell you nothing happened.\n\nExpect real volume on this setting.')
int al_lvl = in_allevel == 'Setups only' ? 1 : 2
// Derived from the level above so every f_alert call site stays untouched.
// Tier 1 = one setup's lifecycle. Tier 2 adds the three that belong to no setup.
bool al_c2        = al_lvl >= 1
bool al_cisd_arm  = al_lvl >= 1
bool al_cisd_conf = al_lvl >= 1
bool al_retest    = al_lvl >= 1
bool al_fc2       = al_lvl >= 1
bool al_sd        = al_lvl >= 1
bool al_stack     = al_lvl >= 1
bool al_opp       = al_lvl >= 1     // v4.53 - additionally gated by in_opp_on at the call site
bool al_sweep     = al_lvl >= 2
bool al_smt       = al_lvl >= 2
bool al_cont      = al_lvl >= 2
string alert_format = input.string('Plain', 'Message format', options = ['Plain', 'Discord webhook'], group = g_alerts, active = alerts_on, tooltip = 'Plain = readable text for app push / email / popup. Discord webhook = wraps the same text as {"content":"..."} JSON, so a Discord channel webhook URL pasted into the TradingView alert Webhook URL field posts straight into the channel.')

// Emit one alert event. Gated to the confirmed bar update so realtime rollback can never
// fire it intrabar (no tick spam), and freq_all so multiple slots firing on the same
// chart bar ALL get through (freq_once_per_bar would silently swallow the second one).
// Rollback-proof duplicate suppression: the alert runtime RECALCULATES a bar's closing
// update as each request.security feed syncs in, and every recalculation re-runs these
// alert calls -> identical messages, same second. Plain `var` buffers are useless
// because rollback wipes them between those recalculations; `varip` state survives
// recalculation, so a message sent once within the window stays remembered and every
// re-send is dropped. Tightest legitimate repeat spacing on any event is one 15m HTF
// candle, well outside the 10-minute window.
//
// v4.17: dedup buffer is GLOBAL, not function-local. Pine instantiates a function-local
// `varip` PER CALL SITE, so the old per-call `sentMsg` gave each of the ~15 f_alert call
// sites its OWN private buffer. CONFIRMED fires from TWO call sites - the instant chart-
// side confirm (LTF close) and the engine backup confirm (next LTF open, ~1 bar later) -
// and with separate buffers neither could see the other's message, so the "identical text
// -> dedup absorbs the second" arbitration was a silent no-op and CONFIRMED doubled every
// time both paths aligned. A shared global buffer lets any call site suppress an identical
// message from any other within the 10-minute window. (Single-call-site events never
// doubled, which is why only CONFIRMED showed it.)
varip array<string> g_sentMsg = array.new<string>()
varip array<int>    g_sentT   = array.new<int>()

// v4.46: `inKz` is the killzone gate and it lives HERE on purpose. One condition in the
// transport reaches all 22 call sites, and putting it anywhere else would have meant
// touching the engine - which is exactly what this feature is not allowed to do.
//
// v4.47: the confirmed-bar gate is a PARAMETER now, matching MM IFVG v6.32 exactly - both
// PRO files run the identical transport signature. 21 of the 22 call sites pass true and
// behave precisely as they always have. ONE passes false: the SD level hit.
// Ryan's call: "Instant for sure no doubt." An SD level is a TARGET, not an entry trigger.
// Learning at the close of a 1m bar that price tagged your -2 up to sixty seconds ago is a
// worse failure than the occasional ping from a wick that gets retraced inside the bar.
// The anti-tick-spam reasoning above still holds for everything else, and for the SD event
// the varip buffer is what replaces it: that message is built from the LEVEL price
// (line.get_y1) and the multiple, never from the live price, so every intrabar
// recalculation produces byte-identical text and the dedupe absorbs it. A deterministic
// message is what makes dropping the gate safe here and would NOT make it safe elsewhere.
f_alert(bool en, string msg, bool needConf) =>
    if alerts_on and en and inKz and (not needConf or barstate.isconfirmed)
        bool dup = false
        if g_sentMsg.size() > 0
            for i = g_sentMsg.size() - 1 to 0
                if time - g_sentT.get(i) > 600000
                    break
                if g_sentMsg.get(i) == msg
                    dup := true
                    break
        if not dup
            g_sentMsg.push(msg)
            g_sentT.push(time)
            if g_sentMsg.size() > 40                  // shared across all events now - bigger headroom
                g_sentMsg.shift()
                g_sentT.shift()
            string m = 'MM HTF | ' + syminfo.ticker + ' | ' + msg
            if alert_format == 'Discord webhook'
                m := '{"content":"' + m + '"}'
            alert(m, alert.freq_all)

// Build the CONFIRMED trade-card message. Single source of truth: the instant chart-side
// confirm and the engine-path backup confirm must produce IDENTICAL text so the varip
// dedup guard suppresses whichever arrives second.
f_confMsg(string pairTag, bool isBull, float lvl, float c2x, float o, float s) =>
    string tgt = ''
    if not na(o) and not na(s) and array.size(sd_levels_arr) > 0
        float u = s - o
        // v4.28: EVERY configured projection, not just the first two. This message is where
        // the whole setup gets handed over - level, stop reference and all of its targets -
        // so a third or fourth SD level no longer gets silently dropped from it.
        for i = 0 to array.size(sd_levels_arr) - 1
            float m = array.get(sd_levels_arr, i)
            tgt := tgt + (i == 0 ? ' | targets: ' : ' / ') + str.tostring(o - m * u, format.mintick) + ' (' + str.tostring(m) + 'SD)'
    pairTag + (isBull ? ' Bull' : ' Bear') + ' CONFIRMED @ ' + str.tostring(lvl, format.mintick) + ' | stop ref: C2 ' + (isBull ? 'low ' : 'high ') + str.tostring(c2x, format.mintick) + tgt

// - - - - - - - - -
// SMT stream - C2-close divergence on the HTF mini candles. One instance per HTF
// slot at that slot's timeframe. Trigger = the fractal C2: a completed tf candle
// sweeps the PRIOR candle's low/high and closes back inside. At that close each
// correlate is checked against ITS OWN prior candle: chart sweeps + correlate holds
// = SMT (mirror: correlate prints the C2 while the chart holds). Candle pairs are
// time-exact across symbols. Events are drawn on the minis by f_markSmt.
f_smtStream(string tf, string tfLbl, bool enabled) =>
    var int lastT = na

    bool use2 = isValidSymbol(g_corr2)
    bool use3 = isValidSymbol(g_corr3)

    // Canonical non-repainting HTF read: lookahead_on + [1]/[2] offsets. [1] = the
    // last CONFIRMED HTF bar (the C2), [2] = the one before (C1). Identical on
    // history and realtime, same confirmed bar across all three symbols.
    [sH, sL, sC, sT, pH, pL] = request.security(syminfo.tickerid, tf, [high[1], low[1], close[1], time[1], high[2], low[2]], lookahead = barmerge.lookahead_on)
    [h2, l2, c2, p2H, p2L] = request.security(use2 ? g_corr2 : syminfo.tickerid, tf, [high[1], low[1], close[1], high[2], low[2]], lookahead = barmerge.lookahead_on)
    [h3, l3, c3, p3H, p3L] = request.security(use3 ? g_corr3 : syminfo.tickerid, tf, [high[1], low[1], close[1], high[2], low[2]], lookahead = barmerge.lookahead_on)

    bool evC2b = false
    string evC2bSym = ''
    bool evC2bChartSwept = false
    bool evC2s = false
    string evC2sSym = ''
    bool evC2sChartSwept = false

    // Evaluate only on settled chart bars so cross-symbol series are both rolled.
    bool barSettled = barstate.ishistory or barstate.isconfirmed
    if enabled and barSettled and not na(sT) and not na(sH) and (na(lastT) or lastT != sT)
        lastT := sT
        if not na(pL) and not na(pH)
            // Ryan's law (v4.22): both sides taken = indecisive. Tested per SYMBOL, so a
            // correlate's outside bar can't be counted as its C2 either. Inversion-
            // agnostic: inverting swaps which side reads bullish, not whether both went.
            // v4.27 Mauler Mode: unless the close landed BEYOND one of C1's extremes, in
            // which case that candle keeps its C2 and stays eligible for the SMT pairing.
            // Judged per symbol for the same reason the two-sided test is - each leg of
            // an SMT has to stand on its own candle.
            bool sTwo = sL < pL and sH > pH
            bool c2Two = use2 and not na(p2L) and not na(p2H) and l2 < p2L and h2 > p2H
            bool c3Two = use3 and not na(p3L) and not na(p3H) and l3 < p3L and h3 > p3H
            bool sBoth = in_nobothsw and sTwo and not (in_mauler and (sC > pH or sC < pL))
            bool c2Both = in_nobothsw and c2Two and not (in_mauler and (c2 > p2H or c2 < p2L))
            bool c3Both = in_nobothsw and c3Two and not (in_mauler and (c3 > p3H or c3 < p3L))
            bool chartC2b = not sBoth and sL < pL and sC > pL
            bool chartHeldB = sL >= pL
            bool c2HeldB = use2 and not na(p2L) and not na(p2H) and (g_inv2 ? h2 <= p2H : l2 >= p2L)
            bool c3HeldB = use3 and not na(p3L) and not na(p3H) and (g_inv3 ? h3 <= p3H : l3 >= p3L)
            bool c2C2b = not c2Both and use2 and not na(p2L) and not na(p2H) and (g_inv2 ? h2 > p2H and c2 < p2H : l2 < p2L and c2 > p2L)
            bool c3C2b = not c3Both and use3 and not na(p3L) and not na(p3H) and (g_inv3 ? h3 > p3H and c3 < p3H : l3 < p3L and c3 > p3L)
            bool chartC2s = not sBoth and sH > pH and sC < pH
            bool chartHeldS = sH <= pH
            bool c2HeldS = use2 and not na(p2L) and not na(p2H) and (g_inv2 ? l2 >= p2L : h2 <= p2H)
            bool c3HeldS = use3 and not na(p3L) and not na(p3H) and (g_inv3 ? l3 >= p3L : h3 <= p3H)
            bool c2C2s = not c2Both and use2 and not na(p2L) and not na(p2H) and (g_inv2 ? l2 < p2L and c2 > p2L : h2 > p2H and c2 < p2H)
            bool c3C2s = not c3Both and use3 and not na(p3L) and not na(p3H) and (g_inv3 ? l3 < p3L and c3 > p3L : h3 > p3H and c3 < p3H)

            if chartC2b and (c2HeldB or c3HeldB)
                evC2b := true
                evC2bSym := f_symName(c2HeldB ? g_corr2 : g_corr3)
                evC2bChartSwept := true
            else if chartHeldB and (c2C2b or c3C2b)
                evC2b := true
                evC2bSym := f_symName(c2C2b ? g_corr2 : g_corr3)
                evC2bChartSwept := false
            if chartC2s and (c2HeldS or c3HeldS)
                evC2s := true
                evC2sSym := f_symName(c2HeldS ? g_corr2 : g_corr3)
                evC2sChartSwept := true
            else if chartHeldS and (c2C2s or c3C2s)
                evC2s := true
                evC2sSym := f_symName(c2C2s ? g_corr2 : g_corr3)
                evC2sChartSwept := false

    [evC2b, evC2bSym, evC2bChartSwept, evC2s, evC2sSym, evC2sChartSwept]

// - - - - - - - - -
// CISD engine - runs INSIDE the pair-LTF security context, so all state is tracked
// at true LTF candle granularity regardless of the chart timeframe.
//
// Per LTF bar it: (1) tracks HTF candle boundaries via time(htfTf) and keeps the prior
// HTF candle's high/low (C1 levels); (2) tracks consecutive same-close-direction runs
// (a run's open = the open of its FIRST candle); (3) when a bar raids C1's level while
// making the HTF candle's extreme, captures the delivery run's open as the CISD level;
// (4) on the HTF boundary, if the completed candle printed a C2 (swept and closed back
// inside), arms a hunt at the captured level; (5) confirms the hunt on the first
// LTF CLOSE through the level, kills it if price takes the C2's own extreme.
// Events are exported as monotonic counters so the chart-side sampler never misses one.
f_cisdEngine(string htfTf) =>
    // HTF candle tracking (within this LTF context)
    var float htfH = na
    var float htfL = na
    var float htfC = na
    var float prevHtfH = na
    var float prevHtfL = na
    // Consecutive-run tracking
    var float dnRunOpen = na
    var int dnRunTime = na
    var bool dnLegBroken = true
    var float upRunOpen = na
    var int upRunTime = na
    var bool upLegBroken = true
    // 3-bar swing pivots (fractals) for std-dev anchoring. A swing high = a bar whose
    // neighbours both have LOWER highs; a swing low = a bar whose neighbours both have
    // HIGHER lows. Confirmed one bar late (the right-hand neighbour closes the fractal).
    var float lastSwingHigh = na
    var float lastSwingLow = na
    var int   lastSwingHighT = na
    var int   lastSwingLowT = na
    // Captured CISD levels for the developing HTF candle
    var float capBullLevel = na
    var int capBullTime = na
    var float capBullLegHigh = na
    var int capBullLegHighT = na
    var float capBearLevel = na
    var int capBearTime = na
    var float capBearLegLow = na
    var int capBearLegLowT = na
    // Active hunts
    var bool bullActive = false
    var float bullLevel = na
    var int bullStartTime = na
    var float bullC2Low = na
    var int bullC3Start = na
    var int bullHuntCount = 0
    var int bullConfCount = 0
    var int bullConfTime = na
    var bool bearActive = false
    var float bearLevel = na
    var int bearStartTime = na
    var float bearC2High = na
    var int bearC3Start = na
    var int bearHuntCount = 0
    var int bearConfCount = 0
    var int bearConfTime = na
    // v4.45: the close that CONFIRMED the setup - the model's entry price. Captured at the
    // same instant as the ConfTime fields above, in the same function, and handed back on
    // the same tuple across the same request.security boundary, so it inherits ConfTime's
    // non-repaint behaviour exactly. No new security call and no new idiom to reason about.
    var float bullConfClose = na
    var float bearConfClose = na
    // Std-dev projection anchors, snapshotted per armed hunt: origin = leg origin wick
    // (bull = down-leg high, bear = up-leg low), sweep = C2 raid extreme.
    var float bullLegOrigin = na
    var float bullLegSweep = na
    var int   bullLegOriginT = na
    var float bearLegOrigin = na
    var float bearLegSweep = na
    var int   bearLegOriginT = na

    float htfChg = ta.change(time(htfTf))
    bool newHtf = not na(htfChg) and htfChg != 0

    if newHtf
        // The just-completed HTF candle is a potential C2
        // Ryan's law (v4.22): both sides taken = indecisive, no C2 either way.
        // v4.27 Mauler Mode: unless the close landed BEYOND one of C1's extremes. htfC is
        // still the just-completed candle's close here - line ~895 rolls it AFTER this
        // block - so this is the SEALED close, not a developing one.
        bool c2Two = not na(prevHtfH) and not na(prevHtfL) and not na(htfH) and not na(htfL) and htfH > prevHtfH and htfL < prevHtfL
        bool c2Maul = in_mauler and c2Two and not na(htfC) and (htfC > prevHtfH or htfC < prevHtfL)
        bool c2Both = in_nobothsw and c2Two and not c2Maul
        bool c2Bull = not c2Both and not na(prevHtfL) and not na(htfL) and htfL < prevHtfL and htfC > prevHtfL
        bool c2Bear = not c2Both and not na(prevHtfH) and not na(htfH) and htfH > prevHtfH and htfC < prevHtfH
        if c2Bull and not na(capBullLevel)
            bullActive := true
            bullLevel := capBullLevel
            bullStartTime := capBullTime
            bullC2Low := htfL
            bullC3Start := time      // C3 = the candle now opening (right after this C2)
            bullHuntCount += 1
            // std-dev anchors: bull leg swept C1 low -> origin = last swing high, sweep = C2 low
            bullLegOrigin := capBullLegHigh
            bullLegOriginT := capBullLegHighT
            bullLegSweep := htfL
            // Ryan's law (v4.27): POTENTIAL means one thing only - price has not closed
            // through the level yet. It is not a statement about which candle the close
            // belonged to. So if the C2's OWN closing price is already through the level,
            // the CISD is CONFIRMED at the seal and gets drawn that way immediately.
            // This is the 10:00 case: the 15m bar whose close SEALS the 4H C2 also closes
            // above the CISD. Both are true at the same instant. The confirm test below
            // (~line 1014) only accepts bars in C3 or later, so it can never see that bar -
            // the setup sat POTENTIAL until ~10:30 while price was already through and
            // retesting. Checked here instead, where the C2 is final.
            // Deliberately at the SEAL and not mid-C2: capBullLevel RE-ANCHORS every time
            // price sweeps a new low inside the candle (~line 990), so a bar at 09:00
            // closing above the level as it stood at 09:00 would be confirming against a
            // level that no longer exists by 10:00. At the seal both the level and the
            // close are final, so there is nothing stale to confirm against.
            if htfC > bullLevel
                bullActive := false
                bullConfCount += 1
                bullConfTime := time
                bullConfClose := htfC     // v4.45: at the seal, htfC IS the sealed C2's close
        if c2Bear and not na(capBearLevel)
            bearActive := true
            bearLevel := capBearLevel
            bearStartTime := capBearTime
            bearC2High := htfH
            bearC3Start := time      // C3 = the candle now opening (right after this C2)
            bearHuntCount += 1
            // std-dev anchors: bear leg swept C1 high -> origin = last swing low, sweep = C2 high
            bearLegOrigin := capBearLegLow
            bearLegOriginT := capBearLegLowT
            bearLegSweep := htfH
            // v4.27: mirror of the bull seal-confirm above - see that comment for the why.
            if htfC < bearLevel
                bearActive := false
                bearConfCount += 1
                bearConfTime := time
                bearConfClose := htfC     // v4.45: mirror of the bull seal path
        prevHtfH := htfH
        prevHtfL := htfL
        htfH := high
        htfL := low
        capBullLevel := na
        capBullTime := na
        capBullLegHigh := na
        capBullLegHighT := na
        capBearLevel := na
        capBearTime := na
        capBearLegLow := na
        capBearLegLowT := na
    else
        htfH := math.max(nz(htfH, high), high)
        htfL := math.min(nz(htfL, low), low)
    htfC := close

    // Delivery-run tracking:
    // the anchor is the open of the first down-close candle after the last
    // up-close candle. ONLY an up-close candle resets the down anchor - wicks,
    // highs/lows, and dojis are all transparent to it. Mirror for the up run.
    // Runs may span HTF boundaries - delivery often starts inside the prior candle.
    bool dnBar = close < open
    bool upBar = close > open

    if upBar
        dnLegBroken := true
    if dnBar and dnLegBroken
        dnRunOpen := open
        dnRunTime := time
        dnLegBroken := false

    if dnBar
        upLegBroken := true
    if upBar and upLegBroken
        upRunOpen := open
        upRunTime := time
        upLegBroken := false

    // Rule-A ratchet: any bar OPENING beyond the current anchor drags the
    // anchor to its own open. A rally therefore pulls a dead down-anchor up with it,
    // so a shallow wick-breach near the top can never capture a stale run from
    // bars ago - the anchor is always at the origin of the CURRENT move.
    if not na(dnRunOpen) and open > dnRunOpen
        dnRunOpen := open
        dnRunTime := time
    if not na(upRunOpen) and open < upRunOpen
        upRunOpen := open
        upRunTime := time

    // Track the most recent 3-bar swing high / swing low on the LTF. These are the
    // std-dev anchors: a bear CISD leg runs from the last swing low (origin "0") up to
    // the sweep swing high ("1"); a bull leg mirrors (last swing high -> sweep swing low).
    if not na(high[2]) and high[1] > high[2] and high[1] > high
        lastSwingHigh := high[1]
        lastSwingHighT := time[1]
    if not na(low[2]) and low[1] < low[2] and low[1] < low
        lastSwingLow := low[1]
        lastSwingLowT := time[1]

    // Capture the CISD anchor, RE-ANCHORING to the final sweep of the candle: bear = the
    // HIGHEST high that breached C1 (low = the lowest that breached C1). Not the first
    // breach - when price sweeps, pulls back, then sweeps AGAIN higher, the delivery-run
    // open and the origin swing move to that last leg.
    // Both anchors are STRUCTURAL - the run open (dn/upRunOpen) and a 3-bar swing (last
    // Swing High/Low) - so a bare noise wick past the extreme re-captures the SAME values;
    // only a genuine pullback-then-resweep (which forms a new swing + run) shifts the mark.
    if not na(prevHtfL) and low < prevHtfL and low <= htfL and not na(dnRunOpen)
        capBullLevel := dnRunOpen
        capBullTime := dnRunTime
        capBullLegHigh := lastSwingHigh
        capBullLegHighT := lastSwingHighT
    if not na(prevHtfH) and high > prevHtfH and high >= htfH and not na(upRunOpen)
        capBearLevel := upRunOpen
        capBearTime := upRunTime
        capBearLegLow := lastSwingLow
        capBearLegLowT := lastSwingLowT

    // Process hunts. Invalidation on a raw trade through the C2 extreme ("taken" =
    // traded, not closed). Confirmation reads close[1] - the previous, always-COMPLETE
    // LTF bar - the moment the next LTF bar opens, and only counts bars belonging to C3
    // or later (time[1] >= c3Start), so the C2's own final bar can never confirm.
    // v4.27: that C3 gate is NOT the whole confirm rule and must not be read as one. The
    // C2's own sealed close is tested at the arm site (~line 894) and confirms there when
    // it is already through the level. This path is only for the setups that armed
    // POTENTIAL - price wasn't through yet - and confirms on a later LTF close. The gate
    // stays because the anchor re-anchors during the C2; see that comment for the why.
    // Confirm is checked BEFORE the kill because the [1] close happened chronologically
    // before anything in the current bar; a post-confirm extreme breach is FC2 territory.
    // NOTE: do NOT gate this on barstate.* - barstate inside a request.security()
    // expression follows the CHART's update cycle, not the requested timeframe's, so a 5m
    // CISD gated on barstate.isconfirmed "confirms" off developing 5m bars when viewed
    // from a 1m chart.
    if bullActive
        if not na(close[1]) and not na(bullC3Start) and time[1] >= bullC3Start and close[1] > bullLevel
            bullActive := false
            bullConfCount += 1
            bullConfTime := time
            bullConfClose := close[1]     // v4.45: the bar that closed through IS the entry
        else if low < bullC2Low
            bullActive := false
    if bearActive
        if not na(close[1]) and not na(bearC3Start) and time[1] >= bearC3Start and close[1] < bearLevel
            bearActive := false
            bearConfCount += 1
            bearConfTime := time
            bearConfClose := close[1]     // v4.45: mirror of the bull path
        else if high > bearC2High
            bearActive := false

    // v4.45: bullConfClose/bearConfClose APPENDED at the end on purpose - destructuring is
    // positional, so adding at the tail cannot shift a single existing field.
    [bullActive, bullLevel, bullStartTime, bullHuntCount, bullConfCount, bullConfTime, bearActive, bearLevel, bearStartTime, bearHuntCount, bearConfCount, bearConfTime, bullLegOrigin, bullLegSweep, bearLegOrigin, bearLegSweep, bullC2Low, bearC2High, bullC3Start, bearC3Start, bullConfClose, bearConfClose]

// v4.52 SIZING ENGINE — ported from MM IFVG v6.42. Kept as its own pair of functions
// rather than left inline so the two files hold the same shape and a future fix lands
// in one recognisable place in each.
//   "one mini does not fit" is just perR > budget. Deliberately NOT a floor()==0 test -
//   the qty expression keeps the exact form that has compiled here since v4.45.
f_size(float ent, float slv) =>
    float perR = math.abs(ent - slv) * syminfo.pointvalue
    bool  micro = perR > pl_risk and pl_micro > 1
    float uR    = micro ? perR / pl_micro : perR
    int   qty   = uR > 0 ? math.max(1, math.floor(pl_risk / uR)) : 1
    [qty, uR * qty, micro]

// The ONLY place the unit noun is chosen. Compact form on purpose - this label tucks at
// the end of the stop line with up to three slots of setups around it.
f_sizeUnit(int qty, bool micro) =>
    micro ? (qty == 1 ? ' micro' : ' micros') : (qty == 1 ? ' mini' : ' minis')

// Build ONE confirmed setup's std-dev ladder and hand it back as the setup's own object.
// anchor0 = leg origin (the "0"), anchor1 = the sweep (the "1"); the parsed multiples
// project past 0 in the delivery direction. v4.19: called at CONFIRM from anchors the hunt
// captured when it armed - same geometry as the old arm-time draw, but a setup that never
// confirms never puts a ladder on the chart. Always returns an object (empty when
// projections are off) so it stays index-parallel with the done pool.
f_buildSD(float anchor0, float anchor1, int cisdStartT, float cisdLvl, float c2Lvl, float confClose, int dir) =>
    SdLadder sd = SdLadder.new(array.new<line>(), array.new<label>(), array.new<bool>())
    // Short segment anchored where the CISD line BEGINS (its start time), running right by
    // SD Extend bars. Not the origin swing, not the confirmation.
    // v4.45: hoisted out of the projection block so the stop line can share them. Same
    // values, same meaning, moved up a scope - and the comment above moved with them, since
    // it describes exactly this geometry and would have been orphaned where it sat.
    // The stop reuses SD Extend rather than owning a second length input, precisely so the
    // two can never drift apart on the chart.
    int barMs = timeframe.in_seconds() * 1000
    int xL = cisdStartT
    int xR = na(cisdStartT) ? int(na) : cisdStartT + barMs * in_sd_extend
    if in_sd_show and not na(anchor0) and not na(anchor1) and not na(cisdStartT) and array.size(sd_levels_arr) > 0
        string stl = f_lstyle(in_sd_style)
        color sdCol = in_sd_color
        float unit = anchor1 - anchor0
        bool showLbl = in_sd_labels != 'Off'
        bool lblLeft = in_sd_labels == 'Left'
        int lblX = lblLeft ? xL : xR
        string lblStyle = lblLeft ? label.style_label_right : label.style_label_left
        for i = 0 to array.size(sd_levels_arr) - 1
            float m = array.get(sd_levels_arr, i)
            float price = anchor0 - m * unit
            sd.lines.push(line.new(xL, price, xR, price, xloc = xloc.bar_time, color = sdCol, style = stl, width = 1))
            sd.hits.push(false)   // v4.44: parity push - never let `hits` fall out of step with `lines`
            if showLbl
                string tag = lblLeft ? ('-' + str.tostring(m) + ' ') : (' -' + str.tostring(m))
                sd.lbls.push(label.new(lblX, price, tag, xloc = xloc.bar_time, style = lblStyle, size = size.tiny, color = COL_NONE, textcolor = sdCol))

    // ── v4.45 TRADE PLAN: stop level + size. Draw-only. Writes nothing back. ──
    // Gated on its own toggle, NOT on in_sd_show - the ladder object is built and pushed
    // either way, so the stop does not inherit the projection switch.
    // Note what is absent on purpose: no hit test, no latch, no state column, no alert.
    // Once this line is drawn the script never looks at it again until the setup that owns
    // it leaves, and f_clearLadder takes it then. That is the whole of Ryan's rule.
    if pl_on and not na(cisdStartT) and not na(cisdLvl)
        float buf = syminfo.mintick * pl_buf
        // Bull setups stop BELOW, bear setups ABOVE - c2Lvl already arrives on the correct
        // side (C2 low for a bull, C2 high for a bear), so that mode needs no direction math.
        float slv = pl_mode == 'C2 Extreme' ? c2Lvl :
             pl_mode == 'CISD Level' ? cisdLvl :
             (dir == 1 ? cisdLvl - buf : cisdLvl + buf)
        if not na(slv) and not na(xR)
            sd.sl := line.new(xL, slv, xR, slv, xloc = xloc.bar_time, color = pl_col, style = f_lstyle(pl_sty), width = pl_wid)
            // Sizing needs a real entry and a non-zero distance. The CISD Level placement
            // can legitimately produce a zero-tick stop, and a contract count off a zero
            // divisor is a lie dressed as a number - so the label simply does not draw.
            if pl_lbl and not na(confClose)
                float perR = math.abs(confClose - slv) * syminfo.pointvalue
                if perR > 0
                    [qty, rsk, isMicro] = f_size(confClose, slv)
                    sd.slLbl := label.new(xR, slv, str.tostring(qty) + f_sizeUnit(qty, isMicro) + ' · $' + str.tostring(math.round(rsk)), xloc = xloc.bar_time, style = label.style_label_left, size = f_szName(pl_lsz), color = COL_NONE, textcolor = pl_txt)
    sd

// Delete one setup's ladder (setup failed inside C3, aged out, or was culled). The ladder
// goes with the setup it belongs to - that pairing is what keeps the pools 1:1.
f_clearLadder(SdLadder sd) =>
    if not na(sd)
        if not na(sd.lines)
            while sd.lines.size() > 0
                line.delete(sd.lines.shift())
        if not na(sd.lbls)
            while sd.lbls.size() > 0
                label.delete(sd.lbls.shift())
        // v4.44: the latch column goes with them. No drawing to delete, but leaving it
        // populated would desync `hits` from `lines` if this object were ever reused.
        if not na(sd.hits)
            sd.hits.clear()
        // v4.45: the stop and its label leave with the setup, same as everything else here.
        // This is the ONLY thing that removes them - never a price event.
        if not na(sd.sl)
            line.delete(sd.sl)
        if not na(sd.slLbl)
            label.delete(sd.slLbl)

// Trim any prior (done-pool) CISD line/label on this slot so it ends no later than
// `boundary` (the start of a newer setup). Guarantees an old setup never intrudes into
// a new one. Size-guarded - Pine runs `for 0 to -1` once on an empty array.
method CapPriorCISD(CisdBook cs, int boundary) =>
    if not na(cs.cisd_done_lines) and cs.cisd_done_lines.size() > 0
        for _i = 0 to cs.cisd_done_lines.size() - 1
            line _l = cs.cisd_done_lines.get(_i)
            if not na(_l) and line.get_x2(_l) > boundary
                line.set_x2(_l, boundary)
    if not na(cs.cisd_done_lbls) and cs.cisd_done_lbls.size() > 0
        for _i = 0 to cs.cisd_done_lbls.size() - 1
            label _b = cs.cisd_done_lbls.get(_i)
            if not na(_b) and label.get_x(_b) > boundary
                label.set_x(_b, boundary)
    cs

// A CONFIRMED setup whose C2 extreme gets wicked (bull: low < C2 low; bear: high > C2 high)
// does NOT vanish - it is marked FAILED: the CISD line is kept but shortened to a stub, its
// "CISD" label dropped, its STD ladder removed, and a red "FC2" tag placed at the C2 swing
// extreme (below the low for a bull setup, above the high for a bear). The kept line is moved
// into a separate failed pool so the live pools stay 1:1. Confirmed setups only; hunting ones
// are handled inside the engine. Iterates high->low so a mid-array removal never shifts an
// index not yet visited. Size-guarded against empty arrays.
method InvalidateConfirmed(CisdBook cs, string pairTag) =>
    if na(cs.cisd_failed_lines)
        cs.cisd_failed_lines := array.new<line>()
    if na(cs.cisd_failed_lbls)
        cs.cisd_failed_lbls := array.new<label>()
    if not na(cs.cisd_done_lines) and cs.cisd_done_lines.size() > 0 and not na(cs.cisd_done_c2)
        int barMs = timeframe.in_seconds() * 1000
        for i = cs.cisd_done_lines.size() - 1 to 0
            float c2 = cs.cisd_done_c2.get(i)
            int dir = cs.cisd_done_dir.get(i)
            int c3end = cs.cisd_done_c3end.get(i)
            bool inC3 = na(c3end) or time < c3end              // only invalidate WHILE inside C3
            bool hit = inC3 and not na(c2) and ((dir == 1 and low < c2) or (dir == -1 and high > c2))
            if hit
                line fl = cs.cisd_done_lines.get(i)
                int fx1 = line.get_x1(fl)
                line.set_x2(fl, math.min(line.get_x2(fl), fx1 + 5 * barMs))    // keep line, shorten to a stub (never lengthen)
                cs.cisd_failed_lines.push(fl)                                   // reuse the object -> keeps its color/style/width
                label.delete(cs.cisd_done_lbls.get(i))                          // drop the CISD label
                label ff = label.new(fx1, c2, 'FC2', xloc = xloc.bar_time, style = (dir == 1 ? label.style_label_up : label.style_label_down), textcolor = color.red, color = color.new(color.red, 100), size = size.small)
                cs.cisd_failed_lbls.push(ff)
                f_alert(al_fc2, pairTag + ' ' + (dir == 1 ? 'Bull' : 'Bear') + ' FAILED (FC2) - C2 ' + (dir == 1 ? 'low ' : 'high ') + str.tostring(c2, format.mintick) + ' taken inside C3. Stand down.', true)
                cs.cisd_done_lines.remove(i)                                    // pull the setup from the live pools
                cs.cisd_done_lbls.remove(i)
                cs.cisd_done_c2.remove(i)
                cs.cisd_done_dir.remove(i)
                cs.cisd_done_c3end.remove(i)
                cs.cisd_done_state.remove(i)
                bool _fs = cs.cisd_done_stack.remove(i)
                if not na(cs.cisd_done_sd) and cs.cisd_done_sd.size() > i
                    f_clearLadder(cs.cisd_done_sd.get(i))                        // the ladder leaves with its setup
                    SdLadder _fd = cs.cisd_done_sd.remove(i)
    cs

// v4.26: retire a DEAD HUNT the way InvalidateConfirmed retires a failed confirmed setup —
// but one volume level down. The line used to be deleted outright, so with Potential CISD on
// you would watch a dashed level get hunted and then simply evaporate, with nothing saying
// why. Now it is kept, stubbed at the bar that killed it, greyed, and a muted x is dropped at
// the C2 extreme that did the killing.
// Deliberately NOT the red FC2 tag and deliberately NO alert: this setup never closed through
// its level, so there was never an entry and there is nothing to stand down from. FC2 has to
// keep meaning "you could have been in this" or it stops being worth looking at.
// Only marks when Potential CISD is on — if the hunt was never drawn, nothing is left behind
// and the chart is exactly as clean as it was before.
method KillHuntMark(CisdBook cs, line ln, label lb, float c2Ext, int dir) =>
    label.delete(lb)                                    // the CISD tag goes either way
    if in_pot_on and not na(ln) and not na(c2Ext)
        if na(cs.cisd_failed_lines)
            cs.cisd_failed_lines := array.new<line>()
        if na(cs.cisd_failed_lbls)
            cs.cisd_failed_lbls := array.new<label>()
        int x1 = line.get_x1(ln)
        line.set_x2(ln, math.min(line.get_x2(ln), x1 + 5 * timeframe.in_seconds() * 1000))  // stub, never lengthen
        line.set_color(ln, color.new(#787b86, 25))
        line.set_style(ln, line.style_dotted)
        cs.cisd_failed_lines.push(ln)                   // shares the FC2 pool, so CapSetups already ages it out
        cs.cisd_failed_lbls.push(label.new(x1, c2Ext, '×', xloc = xloc.bar_time,
             style = (dir == 1 ? label.style_label_up : label.style_label_down),
             textcolor = #787b86, color = COL_NONE, size = size.tiny))
    else
        line.delete(ln)
    cs

// Cap TOTAL setups on a slot (valid done + failed FC2 markers) at `cap`, culling the OLDEST
// across BOTH pools by CISD-line start (line x1 = the setup's start time). A failed marker is
// still a setup, so it ages out on the same budget as a valid one. Culling a valid setup also
// drops the STD ladder it owns, so the two pools stay 1:1 by construction.
method CapSetups(CisdBook cs, int cap) =>
    // Stacked-zone members (cisd_done_stack = true) are EXEMPT from the budget: they
    // neither count toward the cap nor get culled. The zone pass owns those flags and
    // releases them when the zone breaks, after which normal aging applies again.
    bool more = true
    while more
        int fn = na(cs.cisd_failed_lines) ? 0 : cs.cisd_failed_lines.size()
        int dnU = 0
        int firstU = -1
        if not na(cs.cisd_done_lines) and cs.cisd_done_lines.size() > 0
            for i = 0 to cs.cisd_done_lines.size() - 1
                bool prot = not na(cs.cisd_done_stack) and cs.cisd_done_stack.size() > i and cs.cisd_done_stack.get(i)
                if not prot
                    dnU += 1
                    if firstU < 0
                        firstU := i
        if dnU + fn > cap and dnU + fn > 0
            bool cullDone = firstU >= 0 and (fn == 0 or line.get_x1(cs.cisd_done_lines.get(firstU)) <= line.get_x1(cs.cisd_failed_lines.get(0)))
            if cullDone
                line.delete(cs.cisd_done_lines.get(firstU))
                label.delete(cs.cisd_done_lbls.get(firstU))
                line _k1 = cs.cisd_done_lines.remove(firstU)
                label _k2 = cs.cisd_done_lbls.remove(firstU)
                float _k3 = cs.cisd_done_c2.remove(firstU)
                int _k4 = cs.cisd_done_dir.remove(firstU)
                int _k5 = cs.cisd_done_c3end.remove(firstU)
                int _k6 = cs.cisd_done_state.remove(firstU)
                bool _k7 = cs.cisd_done_stack.remove(firstU)
                if not na(cs.cisd_done_sd) and cs.cisd_done_sd.size() > firstU
                    f_clearLadder(cs.cisd_done_sd.get(firstU))
                    SdLadder _k8 = cs.cisd_done_sd.remove(firstU)
            else if fn > 0
                line.delete(cs.cisd_failed_lines.shift())
                label.delete(cs.cisd_failed_lbls.shift())
            else
                more := false
        else
            more := false
    cs

// Post-confirm retest watcher. A confirmed CISD level only becomes retest-armed after
// price fully SEPARATES from it (one whole chart bar entirely beyond the level); the
// first wick back into it then fires ONE retest alert and that setup is done alerting.
// State per setup: 0 = waiting for separation, 1 = separated, 2 = retested. Runs AFTER
// InvalidateConfirmed + CapSetups, so a failed or aged-out setup can never send a retest.
// Separation and tap are mutually exclusive on one bar, so both never fire together.
method CheckRetests(CisdBook cs, string pairTag) =>
    if not na(cs.cisd_done_lines) and cs.cisd_done_lines.size() > 0 and not na(cs.cisd_done_state) and cs.cisd_done_state.size() == cs.cisd_done_lines.size()
        for i = 0 to cs.cisd_done_lines.size() - 1
            int st = cs.cisd_done_state.get(i)
            if st < 2
                float lvl = line.get_y1(cs.cisd_done_lines.get(i))
                int dir = cs.cisd_done_dir.get(i)
                if st == 0
                    if (dir == 1 and low > lvl) or (dir == -1 and high < lvl)
                        cs.cisd_done_state.set(i, 1)
                else
                    if (dir == 1 and low <= lvl) or (dir == -1 and high >= lvl)
                        cs.cisd_done_state.set(i, 2)
                        f_alert(al_retest, pairTag + ' ' + (dir == 1 ? 'Bull' : 'Bear') + ' RETEST - CISD ' + str.tostring(lvl, format.mintick) + ' tapped. Watch for reaction.', true)
    cs

// - - - - - - - - -
// OPPOSING SETUP FLAG (v4.53). A MARKING LAYER. Read this before changing anything in it:
// nothing here feeds the model. Ryan's law, same as CISD FVG - it reads the confirmed pool,
// drops a tag, fires an alert, and stops. No signal, no gate, no state anything consumes.
//
// WHAT IT ANSWERS. A setup's own C3 can sweep the other side and close back inside, which
// makes that candle a C2 in the opposite direction. The first setup does NOT fail for it -
// its C3 never took its own C2 extreme, and past C3 a confirmed setup cannot fail at all
// (Ryan's law v4.25). So a confirmed bull and a confirmed bear sit on one pair, both valid.
// The engine is right to keep both. What it could not say is that they point opposite ways,
// which is the whole picture if you are holding one of them.
//
// Runs AFTER HuntCISD for the same reason CISD FVG and LRLR do: InvalidateConfirmed ->
// CapSetups -> CheckRetests have already run, so the pool read here is this bar's truth with
// failed and aged-out setups already out of it. A conflict against a dead setup is not one.
// Carries HuntCISD's own chart-TF guard: without it a book whose selector went out of range
// would keep its stale lines and this would flag them forever.
method FlagOpposing(CisdBook cs, string htfTf, string ltfTf, bool cisdOn) =>
    if in_opp_on and cisdOn and timeframe.in_seconds(ltfTf) >= timeframe.in_seconds() and timeframe.in_seconds(ltfTf) < timeframe.in_seconds(htfTf)
        if na(cs.cisd_opp_lbls)
            cs.cisd_opp_lbls := array.new<label>()
        // size() > 1 is not just a guard - one setup can never be in conflict with itself,
        // and it also keeps the loop off the `for 0 to -1 runs once` footgun.
        if not na(cs.cisd_done_lines) and cs.cisd_done_lines.size() > 1 and not na(cs.cisd_done_dir)
            int htfMs = timeframe.in_seconds(htfTf) * 1000
            int newX = 0
            int newI = -1
            for i = 0 to cs.cisd_done_lines.size() - 1
                // Newest by CISD-line x1 = the setup that just confirmed into the conflict.
                // Line start, not confirmation time: x1 is the setup's identity everywhere
                // else in this file (CapSetups ages by it), so the latch cannot drift.
                int x = line.get_x1(cs.cisd_done_lines.get(i))
                if x > newX
                    newX := x
                    newI := i
            if newI >= 0 and cs.cisd_opp_marked != newX
                int nd = cs.cisd_done_dir.get(newI)
                int nEnd = cs.cisd_done_c3end.get(newI)
                bool opp = false
                int oppGap = 0
                for i = 0 to cs.cisd_done_lines.size() - 1
                    if i != newI and cs.cisd_done_dir.get(i) == -nd
                        // Ryan's law (v4.53): the opposing C2 has to BE the older setup's own
                        // C3 or C4 candle. Nothing after C4 counts - by then that setup's
                        // sequence has played out, and a fresh C2 is simply the next setup
                        // rather than a conflict with this one. Same C3 -> C4 lifetime the
                        // table row already runs on, so the tag and the Seq cell agree.
                        //
                        // WHY THE GAP IS THE TEST. Both setups store c3end as their OWN C2
                        // seal plus one HTF candle, so that offset cancels and the difference
                        // between the two c3ends IS the candle distance between the two C2s:
                        // one HTF candle means the new C2 is the old setup's C3, two means it
                        // is its C4. Nothing to look up and no third state to keep in step.
                        //
                        // Measured in ms, which inherits exactly the session-gap assumption
                        // c3end is already built on. A weekend or holiday between the two
                        // candles widens the gap past the window, so the flag is SKIPPED
                        // rather than fired wrongly - the safe direction for a marking layer.
                        int gap = nEnd - cs.cisd_done_c3end.get(i)
                        if gap > 0 and gap <= 2 * htfMs
                            opp := true
                            oppGap := gap
                if opp
                    cs.cisd_opp_marked := newX
                    float nlvl = line.get_y1(cs.cisd_done_lines.get(newI))
                    string seqTag = oppGap <= htfMs ? 'C3' : 'C4'
                    string dirTxt = nd == 1 ? 'Bull' : 'Bear'
                    string oldTxt = nd == 1 ? 'Bear' : 'Bull'
                    // Both tags anchor at the line's START with style_label_right, so the box
                    // sits to the LEFT of where the level begins and never collides with the
                    // "CISD 5m" tag parked at the other end. In VOID the line is gone a moment
                    // later, and the mark is the only thing left saying a setup was here.
                    if in_opp_void
                        // v4.53 VOID. This is the ONE branch in this block that touches the
                        // model, and it is opt-in for that reason - same class as Mauler Mode.
                        // Muted rather than deleted outright, the same call v4.26 made for a
                        // dead hunt: nothing in this file is allowed to evaporate with nothing
                        // saying why.
                        cs.cisd_opp_lbls.push(label.new(newX, nlvl, 'OPP ×', xloc = xloc.bar_time, style = label.style_label_right, size = size.tiny, color = COL_NONE, textcolor = color.new(#787b86, 25)))
                        // Removal mirrors CapSetups exactly - every parallel column and the
                        // ladder, or the pools desync and a later setup wears someone else's
                        // C2 extreme. Drawings are deleted BEFORE the arrays shift.
                        line.delete(cs.cisd_done_lines.get(newI))
                        label.delete(cs.cisd_done_lbls.get(newI))
                        line _v1 = cs.cisd_done_lines.remove(newI)
                        label _v2 = cs.cisd_done_lbls.remove(newI)
                        float _v3 = cs.cisd_done_c2.remove(newI)
                        int _v4 = cs.cisd_done_dir.remove(newI)
                        int _v5 = cs.cisd_done_c3end.remove(newI)
                        int _v6 = cs.cisd_done_state.remove(newI)
                        bool _v7 = cs.cisd_done_stack.remove(newI)
                        if not na(cs.cisd_done_sd) and cs.cisd_done_sd.size() > newI
                            f_clearLadder(cs.cisd_done_sd.get(newI))
                            SdLadder _v8 = cs.cisd_done_sd.remove(newI)
                        // The setup CONFIRMED inside HuntCISD before this layer could see it,
                        // so that alert has already gone out. This one closes the loop rather
                        // than leaving a confirm with nothing on the chart to match it.
                        f_alert(al_opp, f_tfName(htfTf) + '-' + f_tfName(ltfTf) + ' OPPOSING SETUP VOIDED - ' + dirTxt + ' CISD ' + str.tostring(nlvl, format.mintick) + ' removed. It formed on the live ' + oldTxt + ' setup\'s own ' + seqTag + '. Disregard the confirm above it.', true)
                    else
                        cs.cisd_opp_lbls.push(label.new(newX, nlvl, 'OPP', xloc = xloc.bar_time, style = label.style_label_right, size = size.tiny, color = COL_NONE, textcolor = in_opp_col))
                        // Silenced with the layer, unlike the C2 alert which is deliberately
                        // ungated: whether a C2 closed is not a drawing question, but this IS
                        // the drawing. The C3/C4 wording is read straight off the gap that
                        // passed the test above, so the message can never name a candle the
                        // rule did not use.
                        f_alert(al_opp, f_tfName(htfTf) + '-' + f_tfName(ltfTf) + ' OPPOSING SETUP - ' + dirTxt + ' CISD ' + str.tostring(nlvl, format.mintick) + ' confirmed off the live ' + oldTxt + ' setup\'s own ' + seqTag + '. Both directions are on the chart.', true)
        // Bounded on the setup budget so tags age out roughly with the lines they belong to.
        // A marking layer must never be able to leak drawings into the 500-label cap.
        while cs.cisd_opp_lbls.size() > in_maxsetups + 1
            label.delete(cs.cisd_opp_lbls.shift())
    cs

// Chart-side CISD hunt: samples the LTF engine and manages the level lines on price.
// htfTf/ltfTf MUST be plain input strings - anything passed into the request.security
// expression must trace back to clean inputs only.
method HuntCISD(CisdBook candleSet, string htfTf, string ltfTf, bool cisdOn) =>
    if cisdOn and timeframe.in_seconds(ltfTf) >= timeframe.in_seconds() and timeframe.in_seconds(ltfTf) < timeframe.in_seconds(htfTf)
        [bA, bLvl, bST, bHC, bCC, bCT, sA, sLvl, sST, sHC, sCC, sCT, boO, boS, soO, soS, bc2L, sc2H, bc3s, sc3s, bConfC, sConfC] = request.security(syminfo.tickerid, ltfTf, f_cisdEngine(htfTf))

        if na(candleSet.cisd_done_lines)
            candleSet.cisd_done_lines := array.new<line>()
        if na(candleSet.cisd_done_lbls)
            candleSet.cisd_done_lbls := array.new<label>()
        if na(candleSet.cisd_done_c2)
            candleSet.cisd_done_c2 := array.new<float>()
        if na(candleSet.cisd_done_dir)
            candleSet.cisd_done_dir := array.new<int>()
        if na(candleSet.cisd_done_c3end)
            candleSet.cisd_done_c3end := array.new<int>()
        if na(candleSet.cisd_done_state)
            candleSet.cisd_done_state := array.new<int>()
        if na(candleSet.cisd_done_sd)
            candleSet.cisd_done_sd := array.new<SdLadder>()
        // Tag names the timeframe the CISD itself lives on (the pair LTF), not the C2's HTF
        string cisdTag = 'CISD ' + f_tfName(ltfTf)
        // Alert pair tag, named from the C2 side: "1H-5m" = 1H C2 setup hunted on the 5m
        string pairTag = f_tfName(htfTf) + '-' + f_tfName(ltfTf)

        // INSTANT CONFIRM (alert only). The chart bar that completes an LTF bar closes
        // at the same moment with the same final price, and CHART-scope barstate is
        // reliable (unlike barstate inside the security context). So when a pair-TF
        // candle is sealing right now, the hunt is live, the sealing bar is C3-or-later,
        // and this close is through the level, the trade card fires AT the close. The
        // engine still books the confirm at the next LTF open (lines, pools, FC2) and
        // re-emits the same text, which the varip dedup absorbs - and if this bar is
        // ever misaligned (odd holiday sessions), the engine path is the backup
        // announcer. Identical text via f_confMsg is what makes that arbitration safe.
        bool ltfClosing = time_close == time_close(ltfTf)
        if ltfClosing
            if bA and not na(bLvl) and not na(bc3s) and time(ltfTf) >= bc3s and close > bLvl
                f_alert(al_cisd_conf, f_confMsg(pairTag, true, bLvl, bc2L, boO, boS), true)
            if sA and not na(sLvl) and not na(sc3s) and time(ltfTf) >= sc3s and close < sLvl
                f_alert(al_cisd_conf, f_confMsg(pairTag, false, sLvl, sc2H, soO, soS), true)

        // BULL side
        if bHC != candleSet.cisd_bull_hunt_count
            candleSet.cisd_bull_hunt_count := bHC
            if not na(candleSet.cisd_bull_line)
                line.delete(candleSet.cisd_bull_line)  // superseded by a newer C2
                label.delete(candleSet.cisd_bull_lbl)
                candleSet.cisd_bull_line := na
                candleSet.cisd_bull_lbl := na
            if not na(bLvl) and not na(bST)
                // v4.19: this level is only POTENTIAL until an LTF bar closes through it. The
                // line object is always created (the confirm path needs it to hand into the
                // done pool) but it is born invisible unless Potential CISD is on, in which
                // case it wears the potential style until the confirm upgrades it.
                candleSet.cisd_bull_line := line.new(bST, bLvl, time, bLvl, xloc = xloc.bar_time, color = in_pot_on ? in_cisd_bull : COL_NONE, style = f_lstyle(in_pot_style), width = in_cisd_width)
                candleSet.cisd_bull_lbl := label.new(time, bLvl, cisdTag, xloc = xloc.bar_time, style = label.style_label_left, size = size.tiny, color = COL_NONE, textcolor = in_pot_on ? in_cisd_bull : COL_NONE)
                // Ladder anchors held here; the ladder itself is built at the confirm
                candleSet.cisd_sdB_a0 := boO
                candleSet.cisd_sdB_a1 := boS
                candleSet.cisd_sdB_st := bST
                // No-intrusion: this new setup starts here - pull any prior line back to it
                candleSet.cisd_newest_start := bST
                candleSet.CapPriorCISD(bST)
                // v4.27: text no longer says "closed back" - a Mauler C2 closed THROUGH the
                // far side, and the arm alert must not describe it as something it wasn't.
                // v4.28: one event, one buzz. Since v4.27 a C2 that seals already through its
                // level arms and confirms on the SAME bar, which fired ARMED then CONFIRMED
                // back to back. The conf counter is read here BEFORE the confirm block below
                // updates it, so a mismatch means the confirm is about to fire this bar -
                // stay quiet and let it announce. The line still draws either way.
                f_alert(al_cisd_arm and bCC == candleSet.cisd_bull_conf_count, pairTag + ' Bull ARMED - C1 low swept. Hunting CISD @ ' + str.tostring(bLvl, format.mintick) + ' | dies below C2 low ' + str.tostring(bc2L, format.mintick), true)
        if bCC != candleSet.cisd_bull_conf_count
            candleSet.cisd_bull_conf_count := bCC
            if not na(candleSet.cisd_bull_line)
                if not na(bCT)
                    int _bcx = (na(candleSet.cisd_newest_start) or line.get_x1(candleSet.cisd_bull_line) >= candleSet.cisd_newest_start) ? bCT : candleSet.cisd_newest_start
                    line.set_x2(candleSet.cisd_bull_line, _bcx)
                    label.set_x(candleSet.cisd_bull_lbl, _bcx)
                // v4.19: confirmation is what makes the level real - the line drops the
                // potential look (or its invisibility) and takes the confirmed style here.
                line.set_color(candleSet.cisd_bull_line, in_cisd_bull)
                line.set_style(candleSet.cisd_bull_line, f_lstyle(in_cisd_style))
                line.set_width(candleSet.cisd_bull_line, in_cisd_width)
                label.set_textcolor(candleSet.cisd_bull_lbl, in_cisd_bull)
                candleSet.cisd_done_lines.push(candleSet.cisd_bull_line)
                candleSet.cisd_done_lbls.push(candleSet.cisd_bull_lbl)
                candleSet.cisd_done_c2.push(bc2L)
                candleSet.cisd_done_dir.push(1)
                candleSet.cisd_done_c3end.push(bc3s + timeframe.in_seconds(htfTf) * 1000)
                candleSet.cisd_done_state.push(0)
                candleSet.cisd_done_stack.push(false)
                // Ladder is built HERE, pushed in the same breath as the setup that owns it -
                // one push site, one removal site, so the two pools cannot drift apart.
                candleSet.cisd_done_sd.push(f_buildSD(candleSet.cisd_sdB_a0, candleSet.cisd_sdB_a1, candleSet.cisd_sdB_st, bLvl, bc2L, bConfC, 1))
                candleSet.cisd_bull_line := na
                candleSet.cisd_bull_lbl := na
            f_alert(al_cisd_conf, f_confMsg(pairTag, true, bLvl, bc2L, boO, boS), true)
        else if not na(candleSet.cisd_bull_line)
            if bA
                int _bax = (na(candleSet.cisd_newest_start) or line.get_x1(candleSet.cisd_bull_line) >= candleSet.cisd_newest_start) ? time : candleSet.cisd_newest_start
                line.set_x2(candleSet.cisd_bull_line, _bax)
                label.set_x(candleSet.cisd_bull_lbl, _bax)
            else
                // v4.26: C2 low taken - hunt dead. Stubbed and x-marked rather than deleted.
                candleSet.KillHuntMark(candleSet.cisd_bull_line, candleSet.cisd_bull_lbl, bc2L, 1)
                candleSet.cisd_bull_line := na
                candleSet.cisd_bull_lbl := na
                candleSet.cisd_sdB_a0 := na           // anchors die with the hunt; no ladder was drawn
                candleSet.cisd_sdB_a1 := na
                candleSet.cisd_sdB_st := na

        // BEAR side
        if sHC != candleSet.cisd_bear_hunt_count
            candleSet.cisd_bear_hunt_count := sHC
            if not na(candleSet.cisd_bear_line)
                line.delete(candleSet.cisd_bear_line)
                label.delete(candleSet.cisd_bear_lbl)
                candleSet.cisd_bear_line := na
                candleSet.cisd_bear_lbl := na
            if not na(sLvl) and not na(sST)
                // v4.19: potential until an LTF bar closes through it - see the bull side
                candleSet.cisd_bear_line := line.new(sST, sLvl, time, sLvl, xloc = xloc.bar_time, color = in_pot_on ? in_cisd_bear : COL_NONE, style = f_lstyle(in_pot_style), width = in_cisd_width)
                candleSet.cisd_bear_lbl := label.new(time, sLvl, cisdTag, xloc = xloc.bar_time, style = label.style_label_left, size = size.tiny, color = COL_NONE, textcolor = in_pot_on ? in_cisd_bear : COL_NONE)
                // Ladder anchors held here; the ladder itself is built at the confirm
                candleSet.cisd_sdS_a0 := soO
                candleSet.cisd_sdS_a1 := soS
                candleSet.cisd_sdS_st := sST
                // No-intrusion: this new setup starts here - pull any prior line back to it
                candleSet.cisd_newest_start := sST
                candleSet.CapPriorCISD(sST)
                // v4.28: mirror of the bull arm suppression - see that comment for the why.
                f_alert(al_cisd_arm and sCC == candleSet.cisd_bear_conf_count, pairTag + ' Bear ARMED - C1 high swept. Hunting CISD @ ' + str.tostring(sLvl, format.mintick) + ' | dies above C2 high ' + str.tostring(sc2H, format.mintick), true)
        if sCC != candleSet.cisd_bear_conf_count
            candleSet.cisd_bear_conf_count := sCC
            if not na(candleSet.cisd_bear_line)
                if not na(sCT)
                    int _scx = (na(candleSet.cisd_newest_start) or line.get_x1(candleSet.cisd_bear_line) >= candleSet.cisd_newest_start) ? sCT : candleSet.cisd_newest_start
                    line.set_x2(candleSet.cisd_bear_line, _scx)
                    label.set_x(candleSet.cisd_bear_lbl, _scx)
                // v4.19: confirmed - drop the potential look, take the confirmed style
                line.set_color(candleSet.cisd_bear_line, in_cisd_bear)
                line.set_style(candleSet.cisd_bear_line, f_lstyle(in_cisd_style))
                line.set_width(candleSet.cisd_bear_line, in_cisd_width)
                label.set_textcolor(candleSet.cisd_bear_lbl, in_cisd_bear)
                candleSet.cisd_done_lines.push(candleSet.cisd_bear_line)
                candleSet.cisd_done_lbls.push(candleSet.cisd_bear_lbl)
                candleSet.cisd_done_c2.push(sc2H)
                candleSet.cisd_done_dir.push(-1)
                candleSet.cisd_done_c3end.push(sc3s + timeframe.in_seconds(htfTf) * 1000)
                candleSet.cisd_done_state.push(0)
                candleSet.cisd_done_stack.push(false)
                candleSet.cisd_done_sd.push(f_buildSD(candleSet.cisd_sdS_a0, candleSet.cisd_sdS_a1, candleSet.cisd_sdS_st, sLvl, sc2H, sConfC, -1))
                candleSet.cisd_bear_line := na
                candleSet.cisd_bear_lbl := na
            f_alert(al_cisd_conf, f_confMsg(pairTag, false, sLvl, sc2H, soO, soS), true)
        else if not na(candleSet.cisd_bear_line)
            if sA
                int _sax = (na(candleSet.cisd_newest_start) or line.get_x1(candleSet.cisd_bear_line) >= candleSet.cisd_newest_start) ? time : candleSet.cisd_newest_start
                line.set_x2(candleSet.cisd_bear_line, _sax)
                label.set_x(candleSet.cisd_bear_lbl, _sax)
            else
                // v4.26: C2 high taken - hunt dead. Stubbed and x-marked rather than deleted.
                candleSet.KillHuntMark(candleSet.cisd_bear_line, candleSet.cisd_bear_lbl, sc2H, -1)
                candleSet.cisd_bear_line := na
                candleSet.cisd_bear_lbl := na
                candleSet.cisd_sdS_a0 := na           // anchors die with the hunt; no ladder was drawn
                candleSet.cisd_sdS_a1 := na
                candleSet.cisd_sdS_st := na

        // Setup cap: TOTAL setups per slot (active hunts + valid confirmed + failed FC2
        // markers) stays at Max setups + 1. A failed marker is still a setup, so valid and
        // failed share ONE budget and the oldest is culled regardless of which it is.
        int activeHunts = (na(candleSet.cisd_bull_line) ? 0 : 1) + (na(candleSet.cisd_bear_line) ? 0 : 1)
        int allowDone = math.max(0, in_maxsetups + 1 - activeHunts)

        // Move any setup that failed inside its C3 into the failed pool, THEN cull valid +
        // failed together down to the shared cap (oldest first; a valid setup's ladder goes with it).
        candleSet.InvalidateConfirmed(pairTag)
        candleSet.CapSetups(allowDone)
        candleSet.CheckRetests(pairTag)

    candleSet

// - - - - - - - - -
// Slot data updates. f_roll seals/opens candles on the HTF boundary and records the
// close-confirmed events (sweep marks, continuation); f_update tracks the developing
// candle and fires the raid alerts. Pure data - nothing here draws.

f_roll(Slot s, simple string tf, bool nc, int maxN) =>
    if nc
        s.o.push(open)
        s.h.push(high)
        s.l.push(low)
        s.c.push(close)
        s.id.push(s.nextId)
        s.nextId := s.nextId + 1
        s.tag.push(f_candleTag(tf, time))
        s.raidHi := false
        s.raidLo := false
        s.devOIdx := bar_index
        s.devHIdx := bar_index
        s.devLIdx := bar_index
        int n = s.o.size()
        if n >= 3
            float c2h = s.h.get(n - 2)
            float c2l = s.l.get(n - 2)
            float c2c = s.c.get(n - 2)
            float c1h = s.h.get(n - 3)
            float c1l = s.l.get(n - 3)
            // Close-confirmed sweep mark: the just-closed candle raided its prior's
            // level and closed back inside. Recorded once, at the close, by id.
            // Ryan's law (v4.22): both sides taken = indecisive, mark nothing.
            // v4.27 Mauler Mode: unless it closed BEYOND one of C1's extremes. That candle
            // resolves to exactly one direction on its own - a close above c1h can't
            // satisfy the bear test (c2c < c1h), a close below c1l can't satisfy the bull
            // test (c2c > c1l) - so no side-picking is needed and swLvl below still lands
            // on the level that was actually swept.
            bool swTwo = c2h > c1h and c2l < c1l
            bool swBoth = in_nobothsw and swTwo and not (in_mauler and (c2c > c1h or c2c < c1l))
            bool swB = not swBoth and c2l < c1l and c2c > c1l
            bool swS = not swBoth and c2h > c1h and c2c < c1h
            // v4.28: C2 CLOSED - fires in EVERY alert tier. Ryan's ask, and it closes the
            // gap the tiering left: a C2 can seal without anything else announcing it.
            // ARMED needs a CISD level to have been captured, and CONFIRMED needs price
            // through that level - so a C2 that seals with neither is silent, and that is
            // exactly the one he wants to go look at.
            // Deliberately OUTSIDE the in_sw_on block below: that toggle governs whether
            // the sweep MARK draws. Whether a C2 closed is not a drawing question.
            // Fires off the slot's own timeframe, so it announces the TFs you chose to
            // watch - three slots on means three of these.
            // The extreme quoted is the C2's OWN low/high - the same level the engine kills
            // a hunt on and the same one FC2 reports. Quoting it here means the first alert
            // of the setup already tells you where it stops being valid.
            if swB
                f_alert(al_c2, f_tfName(tf) + ' Bull C2 CONFIRMED - prior low ' + str.tostring(c1l, format.mintick) + ' swept. Invalid below C2 low ' + str.tostring(c2l, format.mintick) + '. Check the chart.', true)
            if swS
                f_alert(al_c2, f_tfName(tf) + ' Bear C2 CONFIRMED - prior high ' + str.tostring(c1h, format.mintick) + ' swept. Invalid above C2 high ' + str.tostring(c2h, format.mintick) + '. Check the chart.', true)
            // v4.20: ONE sweep mark per mini TF. Every sweep in the visible history used to
            // stay drawn, so a panel with any range-bound stretch in it filled with level
            // lines and buried the sweep that actually matters - the last one. A new
            // close-confirmed sweep now replaces the previous one outright.
            // v4.48: no longer gated on in_sw_on. That toggle governs whether the sweep
            // LINE draws; these IDs are what the C1/C2/C3 labels read, and they are also
            // just the plain record of which candles the setup is made of. Same split the
            // C2 alert above already makes. The n >= 3 guard is new too - s.id.get(n - 3)
            // on a two-candle lane indexes -1, reachable in principle since v4.20 and
            // never yet fired, which is luck rather than a guarantee.
            if (swB or swS) and n >= 3
                s.swC2.clear()
                s.swC1.clear()
                s.swLvl.clear()
                s.swBull.clear()
                s.swC2.push(s.id.get(n - 2))
                s.swC1.push(s.id.get(n - 3))
                s.swLvl.push(swB ? c1l : c1h)
                s.swBull.push(swB)     // v4.49
            // CONTINUATION: the just-closed candle took the prior candle's
            // high/low and CLOSED through it - expansion, not a reversal setup.
            // v4.27: unless it swept the OPPOSITE side first. That candle is now a C2 by
            // Ryan's law above and it just got a sweep mark, so calling it "expansion, no
            // C2" in the same breath would have the alert contradicting the chart.
            if c2c > c1h and not swB
                f_alert(al_cont, f_tfName(tf) + ' continuation Bull - closed above prior high ' + str.tostring(c1h, format.mintick) + '. Expansion, no C2.', true)
            if c2c < c1l and not swS
                f_alert(al_cont, f_tfName(tf) + ' continuation Bear - closed below prior low ' + str.tostring(c1l, format.mintick) + '. Expansion, no C2.', true)
        // Age off the oldest candle and purge any marks anchored to it
        while s.o.size() > maxN
            int dropId = s.id.get(0)
            float _f1 = s.o.shift()
            float _f2 = s.h.shift()
            float _f3 = s.l.shift()
            float _f4 = s.c.shift()
            int _i1 = s.id.shift()
            string _s1 = s.tag.shift()
            if s.swC2.size() > 0
                for i = s.swC2.size() - 1 to 0
                    if s.swC1.get(i) == dropId or s.swC2.get(i) == dropId
                        int _r1 = s.swC2.remove(i)
                        int _r2 = s.swC1.remove(i)
                        float _r3 = s.swLvl.remove(i)
                        bool _r4 = s.swBull.remove(i)     // v4.49 — EVERY column, or the rows desync
            if s.smFrom.size() > 0
                for i = s.smFrom.size() - 1 to 0
                    if s.smFrom.get(i) == dropId or s.smTo.get(i) == dropId
                        int _m1 = s.smFrom.remove(i)
                        int _m2 = s.smTo.remove(i)
                        float _m3 = s.smY1.remove(i)
                        float _m4 = s.smY2.remove(i)
                        bool _m5 = s.smBear.remove(i)
                        string _m6 = s.smSym.remove(i)

f_update(Slot s, simple string tf) =>
    int n = s.o.size()
    if n > 0
        int last = n - 1
        if high > s.h.get(last)
            s.h.set(last, high)
            s.devHIdx := bar_index
        if low < s.l.get(last)
            s.l.set(last, low)
            s.devLIdx := bar_index
        s.c.set(last, close)
        // RAID ALERT: fires the moment the developing candle trades through the prior
        // candle's high/low - the sweep itself, before any close. The close-back verdict
        // belongs to ARMED/SMT; the on-chart sweep LINES stay close-confirmed. One alert
        // per side per candle via the raid flags, reset when the next candle opens.
        if n >= 2
            float pH = s.h.get(last - 1)
            float pL = s.l.get(last - 1)
            if not s.raidHi and s.h.get(last) > pH
                s.raidHi := true
                f_alert(al_sweep, f_tfName(tf) + ' sweep Bear developing - prior high ' + str.tostring(pH, format.mintick) + ' taken. Close back inside = C2.', true)
            if not s.raidLo and s.l.get(last) < pL
                s.raidLo := true
                f_alert(al_sweep, f_tfName(tf) + ' sweep Bull developing - prior low ' + str.tostring(pL, format.mintick) + ' taken. Close back inside = C2.', true)

// Record this bar's C2 SMT events against the slot's minis, by candle id. The stream
// fires on the first chart bar of the NEXT HTF candle - by then f_roll has appended
// the new candle, so the completed C2 sits at size-2 and its C1 at size-3.
f_markSmt(Slot s, bool c2b, string symB, bool c2s, string symS) =>
    int n = s.o.size()
    if n >= 3 and (c2b or c2s)
        int idC2 = s.id.get(n - 2)
        int idC1 = s.id.get(n - 3)
        if c2b
            s.smFrom.push(idC1)
            s.smTo.push(idC2)
            s.smY1.push(s.l.get(n - 3))
            s.smY2.push(s.l.get(n - 2))
            s.smBear.push(false)
            s.smSym.push(symB)
        if c2s
            s.smFrom.push(idC1)
            s.smTo.push(idC2)
            s.smY1.push(s.h.get(n - 3))
            s.smY2.push(s.h.get(n - 2))
            s.smBear.push(true)
            s.smSym.push(symS)

// - - - - - - - - -
// Render. Disposable projection: wipe the slot's drawing pools, rebuild everything
// from the data arrays at the current lane position. Candle ids resolve to lane
// indices in O(1) because ids are contiguous.

f_wipe(Slot s) =>
    while s.dB.size() > 0
        box.delete(s.dB.pop())
    while s.dL.size() > 0
        line.delete(s.dL.pop())
    while s.dT.size() > 0
        label.delete(s.dT.pop())

f_idPos(Slot s, int idv) =>
    int base = s.nextId - s.o.size()
    int p = idv - base
    p >= 0 and p < s.o.size() ? p : -1

// Draw one slot's lane starting at x0. Returns the next lane's x0.
f_render(Slot s, simple string tf, int x0, float gTop, float gBot) =>
    f_wipe(s)
    int n = s.o.size()
    int step = CW + in_gap
    if n > 0
        // z-order 1: imbalances (FVG = 3-candle gap, VI = 2-candle body gap), computed
        // straight from the price arrays each pass so the developing candle's gaps
        // live-update and filled gaps vanish - no stored imbalance state at all.
        if in_fvg_on and n >= 3
            for i = 2 to n - 1
                float aBodLo = math.min(s.o.get(i), s.c.get(i))
                float aBodHi = math.max(s.o.get(i), s.c.get(i))
                float bBodLo = math.min(s.o.get(i - 2), s.c.get(i - 2))
                float bBodHi = math.max(s.o.get(i - 2), s.c.get(i - 2))
                int xa = x0 + (i - 2) * step
                int xb = x0 + i * step + CW
                if s.l.get(i) > s.h.get(i - 2) and aBodLo > bBodHi
                    s.dB.push(box.new(xa, s.l.get(i), xb, s.h.get(i - 2), bgcolor = in_fvg_col, border_color = COL_NONE))
                if s.h.get(i) < s.l.get(i - 2) and aBodHi < bBodLo
                    s.dB.push(box.new(xa, s.l.get(i - 2), xb, s.h.get(i), bgcolor = in_fvg_col, border_color = COL_NONE))
        if in_vi_on and n >= 2
            for i = 1 to n - 1
                float aBodLo = math.min(s.o.get(i), s.c.get(i))
                float aBodHi = math.max(s.o.get(i), s.c.get(i))
                float bBodLo = math.min(s.o.get(i - 1), s.c.get(i - 1))
                float bBodHi = math.max(s.o.get(i - 1), s.c.get(i - 1))
                int xa = x0 + (i - 1) * step
                int xb = x0 + i * step + CW
                if s.l.get(i) < s.h.get(i - 1) and aBodLo > bBodHi
                    s.dB.push(box.new(xa, aBodLo, xb, bBodHi, bgcolor = in_vi_col, border_color = COL_NONE))
                if s.h.get(i) > s.l.get(i - 1) and aBodHi < bBodLo
                    s.dB.push(box.new(xa, bBodLo, xb, aBodHi, bgcolor = in_vi_col, border_color = COL_NONE))
        // z-order 2: candle bodies + wicks (+ interval tags)
        for i = 0 to n - 1
            int xl = x0 + i * step
            int xm = xl + 1
            bool up = s.c.get(i) > s.o.get(i)
            float bodHi = math.max(s.o.get(i), s.c.get(i))
            float bodLo = math.min(s.o.get(i), s.c.get(i))
            s.dB.push(box.new(xl, bodHi, xl + CW, bodLo, up ? in_bull_border : in_bear_border, 1, bgcolor = up ? in_bull_body : in_bear_body))
            color wc = up ? in_bull_wick : in_bear_wick
            s.dL.push(line.new(xm, s.h.get(i), xm, bodHi, color = wc))
            s.dL.push(line.new(xm, bodLo, xm, s.l.get(i), color = wc))
            if in_dow_on
                s.dT.push(label.new(xm, s.h.get(i), s.tag.get(i), color = COL_NONE, textcolor = in_dow_col, style = label.style_label_down, size = in_dow_size))
        // z-order 3: sweep marks (C1 wick -> C3 wick, or the C2's right edge while no
        // C3 exists yet), then SMT marks (wick to wick + correlate tag)
        if in_sw_on and s.swC2.size() > 0
            for i = 0 to s.swC2.size() - 1
                int p1 = f_idPos(s, s.swC1.get(i))
                int p2 = f_idPos(s, s.swC2.get(i))
                if p1 >= 0 and p2 >= 0
                    int xa = x0 + p1 * step + 1
                    int xb = p2 + 1 < n ? x0 + (p2 + 1) * step + 1 : x0 + p2 * step + CW
                    float yv = s.swLvl.get(i)
                    s.dL.push(line.new(xa, yv, xb, yv, color = in_sw_col, style = f_lstyle(in_sw_style), width = 1))
        // z-order 3b: C1 / C2 / C3 designations (v4.48). Reads the same newest-only sweep
        // pool the mark above reads, but on its OWN toggle - the pool is a record of which
        // candles the setup is made of, not a drawing. Anchored at each candle's LOW with
        // style_label_up so the box hangs BELOW the wick, mirroring how the interval tag
        // hangs above the high. C3 is simply the position after C2, and it gets tagged
        // while still developing on purpose: that is the candle you are hunting in.
        if in_c123_on and s.swC2.size() > 0 and s.swC1.size() > 0 and s.swBull.size() > 0
            int cp1 = f_idPos(s, s.swC1.get(0))
            int cp2 = f_idPos(s, s.swC2.get(0))
            // v4.49: one decision, taken once, applied to all three. A bull setup raided
            // the LOW, so its tags hang under the lows; a bear setup raided the HIGH, so
            // its tags sit above the highs. style_label_up puts the box BELOW its anchor
            // and style_label_down puts it ABOVE, same pair the interval tag already uses.
            bool cBull = s.swBull.get(0)
            string cSty = cBull ? label.style_label_up : label.style_label_down
            if cp1 >= 0
                s.dT.push(label.new(x0 + cp1 * step + 1, cBull ? s.l.get(cp1) : s.h.get(cp1), 'C1', color = COL_NONE, textcolor = in_c123_col, style = cSty, size = in_c123_size))
            if cp2 >= 0
                s.dT.push(label.new(x0 + cp2 * step + 1, cBull ? s.l.get(cp2) : s.h.get(cp2), 'C2', color = COL_NONE, textcolor = in_c123_col, style = cSty, size = in_c123_size))
                // Guarded, not assumed: a C2 that closed on the newest candle in the lane
                // has no C3 yet, and get(n) would be out of range.
                if cp2 + 1 < n
                    // v4.50: failed = the C2 extreme taken. STRICT comparisons, matching
                    // InvalidateConfirmed's `low < c2` / `high > c2` exactly - an equal
                    // low is not a break there and must not be one here, or the tag and
                    // the FC2 alert would disagree about the same candle.
                    bool c3Fail = cBull ? s.l.get(cp2 + 1) < s.l.get(cp2) : s.h.get(cp2 + 1) > s.h.get(cp2)
                    s.dT.push(label.new(x0 + (cp2 + 1) * step + 1, cBull ? s.l.get(cp2 + 1) : s.h.get(cp2 + 1), c3Fail ? 'F3' : 'C3', color = COL_NONE, textcolor = c3Fail ? in_c123_fcol : in_c123_col, style = cSty, size = in_c123_size))
        if s.smFrom.size() > 0
            for i = 0 to s.smFrom.size() - 1
                int pf = f_idPos(s, s.smFrom.get(i))
                int pt = f_idPos(s, s.smTo.get(i))
                if pf >= 0 and pt >= 0
                    int xa = x0 + pf * step + 1
                    int xb = x0 + pt * step + 1
                    bool isB = s.smBear.get(i)
                    color mc = isB ? in_smt_bear : in_smt_bull
                    s.dL.push(line.new(xa, s.smY1.get(i), xb, s.smY2.get(i), color = mc, style = f_lstyle(in_smt_style), width = 1))
                    // v4.51: X and Y both follow the chosen end. Center keeps the extreme
                    // it always used, so existing charts do not shift; the ends take their
                    // OWN price, because a diagonal line's endpoint is not its extreme and
                    // a label pinned to the extreme would drift off the end it names.
                    float ly = in_smt_lbl == 'Left' ? s.smY1.get(i) :
                         in_smt_lbl == 'Right' ? s.smY2.get(i) :
                         (isB ? math.max(s.smY1.get(i), s.smY2.get(i)) : math.min(s.smY1.get(i), s.smY2.get(i)))
                    int lx = in_smt_lbl == 'Left' ? xa : in_smt_lbl == 'Right' ? xb : math.round(math.avg(xa, xb))
                    s.dT.push(label.new(lx, ly, s.smSym.get(i), color = COL_NONE, textcolor = mc, style = isB ? label.style_label_down : label.style_label_up, size = size.tiny))
        // z-order 4: slot name + countdown labels. Align mode passes the global
        // extremes in; Follow mode uses this slot's own. Clearance scales with range.
        if in_tag_on
            float yT = na(gTop) ? s.h.max() : gTop
            float yB = na(gBot) ? s.l.min() : gBot
            float clr = (yT - yB) * 0.05
            yT := yT + clr
            yB := yB - clr
            int xc = x0 + (n - 1) * step / 2
            string nm = f_tfName(tf)
            string tTop = nm + (in_tmr_on ? '\n' : '') + (in_dow_on ? '\n' : '')
            string tBot = (in_tmr_on ? '\n' : '') + nm
            string tw = '(' + f_countdown(tf) + ')' + (in_dow_on ? '\n' : '')
            if in_tag_pos != 'Bottom'
                s.dT.push(label.new(xc, yT, tTop, color = COL_NONE, textcolor = in_tag_col, style = label.style_label_down, size = in_tag_size))
                if in_tmr_on
                    s.dT.push(label.new(xc, yT, tw, color = COL_NONE, textcolor = in_tmr_col, style = label.style_label_down, size = in_tmr_size))
            if in_tag_pos != 'Top'
                s.dT.push(label.new(xc, yB, tBot, color = COL_NONE, textcolor = in_tag_col, style = label.style_label_up, size = in_tag_size))
                if in_tmr_on
                    s.dT.push(label.new(xc, yB, tw, color = COL_NONE, textcolor = in_tmr_col, style = label.style_label_up, size = in_tmr_size))
    int used = n > 0 ? n * step - in_gap : 0
    x0 + used + in_slotgap

// Trace lines: connect the anchor slot's developing candle back to the real bars
// that made its O/H/L/C. Disposable, same as everything else.
var array<line> traceLn = array.new<line>()
var array<label> traceLb = array.new<label>()

f_trace(Slot s, int x0) =>
    while traceLn.size() > 0
        line.delete(traceLn.pop())
    while traceLb.size() > 0
        label.delete(traceLb.pop())
    int n = s.o.size()
    if in_trace_on and n > 0
        int step = CW + in_gap
        int xl = x0 + (n - 1) * step
        int xm = xl + 1
        int xr = xl + CW
        float vO = s.o.get(n - 1)
        float vH = s.h.get(n - 1)
        float vL = s.l.get(n - 1)
        float vC = s.c.get(n - 1)
        if bar_index - s.devOIdx < 5000
            traceLn.push(line.new(s.devOIdx, vO, xl, vO, xloc = xloc.bar_index, color = in_tr_o_col, style = f_lstyle(in_tr_o_sty), width = in_tr_o_w))
            if in_pxlbl_on
                traceLb.push(label.new(xr, vO, str.tostring(vO), textalign = text.align_center, style = label.style_label_left, size = in_pxlbl_size, color = COL_NONE, textcolor = in_pxlbl_col))
        traceLn.push(line.new(bar_index, vC, xl, vC, xloc = xloc.bar_index, color = in_tr_c_col, style = f_lstyle(in_tr_c_sty), width = in_tr_c_w))
        if in_pxlbl_on
            traceLb.push(label.new(xr, vC, str.tostring(vC), textalign = text.align_center, style = label.style_label_left, size = in_pxlbl_size, color = COL_NONE, textcolor = in_pxlbl_col))
        if bar_index - s.devHIdx < 5000
            traceLn.push(line.new(s.devHIdx, vH, xm, vH, xloc = xloc.bar_index, color = in_tr_h_col, style = f_lstyle(in_tr_h_sty), width = in_tr_h_w))
            if in_pxlbl_on
                traceLb.push(label.new(xr, vH, str.tostring(vH), textalign = text.align_center, style = label.style_label_left, size = in_pxlbl_size, color = COL_NONE, textcolor = in_pxlbl_col))
        if bar_index - s.devLIdx < 5000
            traceLn.push(line.new(s.devLIdx, vL, xm, vL, xloc = xloc.bar_index, color = in_tr_l_col, style = f_lstyle(in_tr_l_sty), width = in_tr_l_w))
            if in_pxlbl_on
                traceLb.push(label.new(xr, vL, str.tostring(vL), textalign = text.align_center, style = label.style_label_left, size = in_pxlbl_size, color = COL_NONE, textcolor = in_pxlbl_col))

// - - - - - - - - -
// EQUAL HIGHS / LOWS. Ported from MM Tbl+Lvl (same engine, same behavior).
// Tracks consecutive wicks at matching prices on the chart TF. When a new candle
// exceeds a tracked high (or breaks below a tracked low), the level is broken and
// its line is deleted. Lines extend to the most recent matching candle. Optional
// right-extend keeps active levels visible until taken. An unbroken EQH/EQL sitting
// inside a fractal setup is the raid target reading this indicator hunts.
type EqlPt
    float price
    int   t
    int   ot

var array<EqlPt>     eqlHi    = array.new<EqlPt>()
var array<EqlPt>     eqlLo    = array.new<EqlPt>()
var map<float, line> eqhLnMap = map.new<float, line>()
var map<float, line> eqlLnMap = map.new<float, line>()

eqlStyle  = eqlLineStyleStr == 'Solid' ? line.style_solid : eqlLineStyleStr == 'Dashed' ? line.style_dashed : line.style_dotted
eqlExtend = eqlExtendRight ? extend.right : extend.none
eqlTolPx  = syminfo.mintick * eqlTolTicks
eqlBarMs  = timeframe.in_seconds() * 1000

if showEql and barstate.isconfirmed
    // Highs: pop tracked highs the current bar exceeded
    while array.size(eqlHi) > 0
        EqlPt topH = array.last(eqlHi)
        if high > topH.price + eqlTolPx
            line lnH = map.get(eqhLnMap, topH.price)
            if not na(lnH)
                line.delete(lnH)
                map.remove(eqhLnMap, topH.price)
            array.pop(eqlHi)
        else
            break

    int hiOt = time
    if array.size(eqlHi) > 0
        EqlPt topH2 = array.last(eqlHi)
        if math.abs(high - topH2.price) <= eqlTolPx
            int firstT  = math.min(topH2.ot, topH2.t)
            int gapBars = eqlBarMs > 0 ? int((time - firstT) / eqlBarMs) : 0
            if gapBars >= eqlMinBars
                line ln = map.get(eqhLnMap, topH2.price)
                if na(ln)
                    line newLn = line.new(x1 = firstT, y1 = topH2.price, x2 = time, y2 = topH2.price, xloc = xloc.bar_time, color = eqhColor, width = eqlLineWidth, style = eqlStyle, extend = eqlExtend)
                    map.put(eqhLnMap, topH2.price, newLn)
                else
                    line.set_x2(ln, time)
            hiOt := firstT
    array.push(eqlHi, EqlPt.new(high, time, hiOt))

    // Lows: pop tracked lows the current bar broke
    while array.size(eqlLo) > 0
        EqlPt topL = array.last(eqlLo)
        if low < topL.price - eqlTolPx
            line lnL = map.get(eqlLnMap, topL.price)
            if not na(lnL)
                line.delete(lnL)
                map.remove(eqlLnMap, topL.price)
            array.pop(eqlLo)
        else
            break

    int loOt = time
    if array.size(eqlLo) > 0
        EqlPt topL2 = array.last(eqlLo)
        if math.abs(low - topL2.price) <= eqlTolPx
            int firstT2  = math.min(topL2.ot, topL2.t)
            int gapBars2 = eqlBarMs > 0 ? int((time - firstT2) / eqlBarMs) : 0
            if gapBars2 >= eqlMinBars
                line ln2 = map.get(eqlLnMap, topL2.price)
                if na(ln2)
                    line newLn2 = line.new(x1 = firstT2, y1 = topL2.price, x2 = time, y2 = topL2.price, xloc = xloc.bar_time, color = eqlColor, width = eqlLineWidth, style = eqlStyle, extend = eqlExtend)
                    map.put(eqlLnMap, topL2.price, newLn2)
                else
                    line.set_x2(ln2, time)
            loOt := firstT2
    array.push(eqlLo, EqlPt.new(low, time, loOt))

// - - - - - - - - -
// HIGHER TIMEFRAME FVGs. Detection core ported from the MM FVG Finder's HTF mode, but
// v4.29 replaced the TIMING with non-repaint idiom A - last CLOSED HTF bar via [1] +
// lookahead_on - because the old form (barstate.isconfirmed inside the tuple, then a
// chart-side rising edge on the flag) drops gaps. Two ways it drops them:
//   1. On historical bars barstate.isconfirmed is true for the whole HTF context, so the
//      gap test ran against the DEVELOPING HTF bar's running low/high. The flag could
//      latch true partway through an HTF bar and simply stay true into the next one - and
//      `flag and not flag[1]` cannot tell "same gap, still true" apart from "new bar,
//      gapped again." In a directional run where HTF bars gap back to back, the streak
//      drew ONE box. That is the missing-daily symptom.
//   2. The same running-value read could flicker true->false->true inside one HTF bar and
//      draw duplicates with the wrong geometry (top = a partial low, not the final low).
// Firing on the HTF bar's TIME changing, off values that are already closed, gives exactly
// one evaluation per closed HTF bar, on finished data, every time.
//
// Ryan's law: an HTF FVG leaves the chart for ONE reason - price wicked ENTIRELY through
// it (bull: low through the bottom edge; bear: high through the top edge). Never on a
// touch, never because a newer gap formed, never because the other slot is busier.
// Anything that removes an unfilled gap is a bug.
//
// Boxes anchor where the 3-candle pattern formed and extend right with price. Menu capped
// at 1D: chart history must hold the HTF candles (Premium = 20,000 intraday bars, ~14 days
// on a 1m chart), and a Weekly pattern alone would eat the whole window. The anchor is
// also clamped 9,500 bars back - drawings can't reference further than ~10k.
f_hfTf(string sel) =>
    sel == '15m' ? '15' : sel == '1H' ? '60' : sel == '4H' ? '240' : '1D'

string hf1Tf = f_hfTf(hf1Sel)
string hf2Tf = f_hfTf(hf2Sel)

// The completed 3-candle pattern sits at [3][2][1] relative to the developing HTF bar:
// c1 = [3] (the gap's far side), c2 = [2] (the displacement body), c3 = [1] (the near
// side). time[1] rides along in the tuple purely as the fire trigger.
[hf1_bull, hf1_bT, hf1_bB, hf1_bear, hf1_sT, hf1_sB, hf1_t, hf1_c] = request.security(syminfo.tickerid, hf1Tf, [low[1] > high[3] and close[2] > open[2], low[1], high[3], high[1] < low[3] and close[2] < open[2], low[3], high[1], time[1], close[1]], lookahead = barmerge.lookahead_on)
[hf2_bull, hf2_bT, hf2_bB, hf2_bear, hf2_sT, hf2_sB, hf2_t, hf2_c] = request.security(syminfo.tickerid, hf2Tf, [low[1] > high[3] and close[2] > open[2], low[1], high[3], high[1] < low[3] and close[2] < open[2], low[3], high[1], time[1], close[1]], lookahead = barmerge.lookahead_on)

// Fire on the HTF BAR changing, not on the flag's rising edge.
bool hf1New = not na(hf1_t) and hf1_t != nz(hf1_t[1], -1)
bool hf2New = not na(hf2_t) and hf2_t != nz(hf2_t[1], -1)
bool newHf1Bull = hf1New and hf1_bull
bool newHf1Bear = hf1New and hf1_bear
bool newHf2Bull = hf2New and hf2_bull
bool newHf2Bear = hf2New and hf2_bear

var array<box> hfBoxes = array.new<box>()
var array<int> hfDirs = array.new<int>()
var array<int> hfSlot = array.new<int>()
var array<int> hfSec = array.new<int>()

// v4.29: per-SLOT budget. The old cap was 60 across both slots, evicted oldest-first, so a
// busy 4H slot quietly shifted out perfectly valid 1D gaps - an unfilled box removed for a
// reason that has nothing to do with price. Each slot now keeps its own budget and can only
// evict its own oldest. 40 x 2 stays inside the script's 500-box ceiling alongside the
// candle lanes (3 x 25 bodies + imbalances) and the stacked-zone pool.
int HF_CAP = 40

// Left anchor = c1 of the pattern. Under idiom A we fire one HTF bar later than the old
// close-of-bar timing, so c1 is 3 HTF bars back, not 2. Clamped to the drawing-reference
// limit. int() cast required - v6 int/int division returns float.
f_hfLeft(string tf) =>
    int bpH = math.max(1, int(timeframe.in_seconds(tf) / timeframe.in_seconds(timeframe.period)))
    math.max(math.max(0, bar_index - MAX_BACK), bar_index - 3 * bpH)   // v4.55: was 9500

// Ryan's law (v4.30): when two HTF FVGs OVERLAP IN PRICE, the higher timeframe owns the
// level and the lower one comes off - simple as that. A 4H gap sitting on a daily gap is
// not a second piece of information, it is the same imbalance drawn twice, and the daily
// is the one price is actually working. Direction-agnostic: a bear 4H overlapping a bull
// daily still yields, because the argument is about the level, not the side.
//
// Removal is permanent, not a suppression, and that is consistent: the daily can only die
// by being wicked through end to end, and the same move wicks through anything nested in
// it. The one case it costs you is a 4H that only clips the daily's edge - the sliver
// outside the daily goes with it. Accepted: partial overlap is still one level.
//
// Touching edges are NOT an overlap (strict >), so a 4H stacked directly on top of a
// daily's boundary survives as its own gap.
f_hfOverlap(float t1, float b1, float t2, float b2) =>
    t1 > b2 and t2 > b1

f_hfAdd(float top, float bot, int dir, int slot, int sec, string tf, color col) =>
    if not na(top) and not na(bot) and top > bot
        // Yield to any higher-TF gap already holding this level.
        bool outranked = false
        if hfBoxes.size() > 0
            for i = 0 to hfBoxes.size() - 1
                if hfSec.get(i) > sec and f_hfOverlap(top, bot, box.get_top(hfBoxes.get(i)), box.get_bottom(hfBoxes.get(i)))
                    outranked := true
                    break
        if not outranked
            // Take the level: clear every lower-TF gap this one overlaps.
            if hfBoxes.size() > 0
                for i = hfBoxes.size() - 1 to 0
                    if hfSec.get(i) < sec and f_hfOverlap(top, bot, box.get_top(hfBoxes.get(i)), box.get_bottom(hfBoxes.get(i)))
                        box.delete(hfBoxes.get(i))
                        box _ob = hfBoxes.remove(i)
                        int _od = hfDirs.remove(i)
                        int _os = hfSlot.remove(i)
                        int _oc = hfSec.remove(i)
            hfBoxes.push(box.new(f_hfLeft(tf), top, bar_index, bot, xloc = xloc.bar_index, bgcolor = col, border_color = COL_NONE, text = f_tfName(tf), text_size = size.tiny, text_halign = text.align_right, text_color = hfTxtCol))
            hfDirs.push(dir)
            hfSlot.push(slot)
            hfSec.push(sec)
            int cnt = 0
            for i = 0 to hfSlot.size() - 1
                if hfSlot.get(i) == slot
                    cnt += 1
            // Evict THIS slot's oldest only - never the other slot's.
            if cnt > HF_CAP
                for i = 0 to hfSlot.size() - 1
                    if hfSlot.get(i) == slot
                        box.delete(hfBoxes.get(i))
                        box _eb = hfBoxes.remove(i)
                        int _ed = hfDirs.remove(i)
                        int _es = hfSlot.remove(i)
                        int _ec = hfSec.remove(i)
                        break

// Dedup: two slots on the same timeframe would double every box - slot 2 yields.
// The seconds value is also the PRECEDENCE RANK above - the ruling is 1D > 4H > 1H > 15m
// by actual duration, never by slot number, so it holds whichever way the slots are set.
int hf1Sec = timeframe.in_seconds(hf1Tf)
int hf2Sec = timeframe.in_seconds(hf2Tf)
// v4.54: strictly-higher by default; hfSameTf relaxes it to equal-or-higher. Lower never draws.
int  hfChartSec = timeframe.in_seconds()
bool hf1On = useHf1 and (hfSameTf ? hf1Sec >= hfChartSec : hf1Sec > hfChartSec)
bool hf2On = useHf2 and (hfSameTf ? hf2Sec >= hfChartSec : hf2Sec > hfChartSec) and not (hf1On and hf2Tf == hf1Tf)

// Higher TF FIRST within the bar, so a same-bar daily takes the level before the 4H can
// claim it. Without this the 4H would draw, then get cleared a statement later - same end
// state, but the ordering makes the intent readable.
bool hfHiFirst = hf1Sec >= hf2Sec

if hfHiFirst and hf1On
    if newHf1Bull
        f_hfAdd(hf1_bT, hf1_bB, 1, 1, hf1Sec, hf1Tf, hf1Bull)
    if newHf1Bear
        f_hfAdd(hf1_sT, hf1_sB, -1, 1, hf1Sec, hf1Tf, hf1Bear)
if hf2On
    if newHf2Bull
        f_hfAdd(hf2_bT, hf2_bB, 1, 2, hf2Sec, hf2Tf, hf2Bull)
    if newHf2Bear
        f_hfAdd(hf2_sT, hf2_sB, -1, 2, hf2Sec, hf2Tf, hf2Bear)
if not hfHiFirst and hf1On
    if newHf1Bull
        f_hfAdd(hf1_bT, hf1_bB, 1, 1, hf1Sec, hf1Tf, hf1Bull)
    if newHf1Bear
        f_hfAdd(hf1_sT, hf1_sB, -1, 1, hf1Sec, hf1Tf, hf1Bear)

// Maintain: extend to the current bar, drop ONLY on a full fill (Ryan's law, above).
if hfBoxes.size() > 0
    for i = hfBoxes.size() - 1 to 0
        box hb = hfBoxes.get(i)
        box.set_right(hb, bar_index)
        int hd = hfDirs.get(i)
        // v4.54: Fill Type. Wick = unchanged v4.30 test. Close = the gap's OWN timeframe candle closing
        // beyond the far edge (Ryan's law: "the close is whatever tf the fvg is on"). hfX_c is that TF's
        // last CLOSED candle (idiom A, close[1] + lookahead_on, appended at the tuple tail - no new
        // security call), and it is only read on the bar that candle lands (hfXNew), so each HTF close
        // is judged exactly once and nothing intrabar can remove a gap.
        int   hsl   = hfSlot.get(i)
        bool  hNew  = hsl == 1 ? hf1New : hf2New
        float hCls  = hsl == 1 ? hf1_c : hf2_c
        bool hfFilled = hfFill == 'Close' ? (hNew and not na(hCls) and ((hd == 1 and hCls < box.get_bottom(hb)) or (hd == -1 and hCls > box.get_top(hb)))) : ((hd == 1 and low <= box.get_bottom(hb)) or (hd == -1 and high >= box.get_top(hb)))
        if hfFilled
            box.delete(hb)
            box _hb = hfBoxes.remove(i)
            int _hd = hfDirs.remove(i)
            int _hs = hfSlot.remove(i)
            int _hc = hfSec.remove(i)

// - - - - - - - - -
// NEARBY LIQUIDITY. Two swing engines run in the backend: MINOR pivots (10 bars
// each side) and MAJOR pivots (40 bars each side). Untaken swing levels are POOLED:
// a new swing landing within an ATR-scaled tolerance of an existing level merges
// into it - equal highs/lows become one pool, the level sits at the cluster's
// extreme (the wick a sweep actually has to run), and the merge count is the pool's
// strength. No timeframe selection by design: pivot spans are chart-bar based and
// the tolerance self-scales from ATR(200), so the engine adapts to any symbol and
// any TF on its own. Display filters to the NEAREST untaken MAJOR and MINOR pool on
// each side of price. A trade through a level deletes it instantly - swept
// liquidity is spent. Level-anchoring logic follows the public liquidity-pool
// indicator class; the implementation and visuals here are original.
int LIQ_MINOR = 5
int LIQ_MAJOR = 40

// v4.15: key-level reads so liquidity pools can skip any level that already
// sits on a key one (PDH/PDL · PWH/PWL · PMH/PML). Non-repaint idiom A —
// last CLOSED HTF bar via [1]+lookahead_on — gated OFF once the chart TF
// reaches/exceeds that level's TF (a daily chart has no meaningful "PDH").
// v4.54: close[1] / high[2] / low[2] appended at the TAIL for the Daily Bias row - same call, no new
// security budget. [1] = yesterday's candle, [2] = the day before it (yesterday's PDH / PDL).
[lqPdhR, lqPdlR, dbC1, dbH2, dbL2] = request.security(syminfo.tickerid, 'D', [high[1], low[1], close[1], high[2], low[2]], lookahead = barmerge.lookahead_on)
[lqPwhR, lqPwlR] = request.security(syminfo.tickerid, 'W', [high[1], low[1]], lookahead = barmerge.lookahead_on)
[lqPmhR, lqPmlR] = request.security(syminfo.tickerid, 'M', [high[1], low[1]], lookahead = barmerge.lookahead_on)
int lqChartSec = timeframe.in_seconds(timeframe.period)
float lqKeyPdh = lqChartSec < 86400   ? lqPdhR : na
float lqKeyPdl = lqChartSec < 86400   ? lqPdlR : na
float lqKeyPwh = lqChartSec < 604800  ? lqPwhR : na
float lqKeyPwl = lqChartSec < 604800  ? lqPwlR : na
float lqKeyPmh = lqChartSec < 2592000 ? lqPmhR : na
float lqKeyPml = lqChartSec < 2592000 ? lqPmlR : na

var array<float> lqHiLvl = array.new<float>()
var array<int> lqHiT = array.new<int>()
var array<int> lqHiN = array.new<int>()
var array<bool> lqHiMaj = array.new<bool>()
var array<float> lqLoLvl = array.new<float>()
var array<int> lqLoT = array.new<int>()
var array<int> lqLoN = array.new<int>()
var array<bool> lqLoMaj = array.new<bool>()
var array<line> lqLines = array.new<line>()
var array<label> lqLbls = array.new<label>()

float lqAtr = ta.atr(200)
float lqTol = nz(lqAtr, syminfo.mintick * 40) / 4
// v4.15: key-level coincidence uses a TIGHTER band than the pooling tolerance.
// A pool must be genuinely hugging the key line to be suppressed — ATR/4 (the
// pooling tol) is wide enough to eat a distinct pool a handful of points off a
// key level (e.g. a real Major SSL just above PWL), leaving the slot empty with
// no replacement. ATR/12 ≈ "on the line."
float lqKeyTol = nz(lqAtr, syminfo.mintick * 40) / 12

float lqPhMin = ta.pivothigh(high, LIQ_MINOR, LIQ_MINOR)
float lqPhMaj = ta.pivothigh(high, LIQ_MAJOR, LIQ_MAJOR)
float lqPlMin = ta.pivotlow(low, LIQ_MINOR, LIQ_MINOR)
float lqPlMaj = ta.pivotlow(low, LIQ_MAJOR, LIQ_MAJOR)

// Merge a confirmed swing into the pool (or open a new pool). Highs cluster to the
// HIGHEST wick of the group, lows to the lowest - the conservative raid line.
f_lqAdd(bool isHigh, float lvl, int t0, bool isMaj) =>
    array<float> lvls = isHigh ? lqHiLvl : lqLoLvl
    array<int> ts = isHigh ? lqHiT : lqLoT
    array<int> ns = isHigh ? lqHiN : lqLoN
    array<bool> mjs = isHigh ? lqHiMaj : lqLoMaj
    int hit = -1
    if lvls.size() > 0
        for i = 0 to lvls.size() - 1
            if math.abs(lvls.get(i) - lvl) <= lqTol
                hit := i
                break
    if hit >= 0
        lvls.set(hit, isHigh ? math.max(lvls.get(hit), lvl) : math.min(lvls.get(hit), lvl))
        ns.set(hit, ns.get(hit) + 1)
        mjs.set(hit, mjs.get(hit) or isMaj)
    else
        lvls.push(lvl)
        ts.push(t0)
        ns.push(1)
        mjs.push(isMaj)
        if lvls.size() > 30
            float _c1 = lvls.shift()
            int _c2 = ts.shift()
            int _c3 = ns.shift()
            bool _c4 = mjs.shift()
    hit

bool lqAnyOn = lqBMajOn or lqBMinOn or lqSMajOn or lqSMinOn
if lqAnyOn
    if not na(lqPhMin)
        f_lqAdd(true, lqPhMin, time[LIQ_MINOR], false)
    if not na(lqPhMaj)
        f_lqAdd(true, lqPhMaj, time[LIQ_MAJOR], true)
    if not na(lqPlMin)
        f_lqAdd(false, lqPlMin, time[LIQ_MINOR], false)
    if not na(lqPlMaj)
        f_lqAdd(false, lqPlMaj, time[LIQ_MAJOR], true)

    // Sweeps: a raw trade through the level spends it - remove instantly
    if lqHiLvl.size() > 0
        for i = lqHiLvl.size() - 1 to 0
            if high > lqHiLvl.get(i)
                float _s1 = lqHiLvl.remove(i)
                int _s2 = lqHiT.remove(i)
                int _s3 = lqHiN.remove(i)
                bool _s4 = lqHiMaj.remove(i)
    if lqLoLvl.size() > 0
        for i = lqLoLvl.size() - 1 to 0
            if low < lqLoLvl.get(i)
                float _s5 = lqLoLvl.remove(i)
                int _s6 = lqLoT.remove(i)
                int _s7 = lqLoN.remove(i)
                bool _s8 = lqLoMaj.remove(i)

// v4.54: which key level (if any) a pool sits on, inside an ATR/12 "on the line" band. Names only -
// never hides a pool. Checks only
// the matching side (highs vs PDH/PWH/PMH, lows vs PDL/PWL/PML); higher TF wins a tie.
f_lqKeyTag(float lvl, bool isHigh) =>
    string k = ''
    if not na(lvl)
        float d = isHigh ? lqKeyPdh : lqKeyPdl
        float w = isHigh ? lqKeyPwh : lqKeyPwl
        float m = isHigh ? lqKeyPmh : lqKeyPml
        if not na(d) and math.abs(lvl - d) <= lqKeyTol
            k := isHigh ? 'PDH' : 'PDL'
        if not na(w) and math.abs(lvl - w) <= lqKeyTol
            k := isHigh ? 'PWH' : 'PWL'
        if not na(m) and math.abs(lvl - m) <= lqKeyTol
            k := isHigh ? 'PMH' : 'PML'
    k

f_lqDraw(float lvl, int t0, int n, bool isHigh, bool isMaj, color lc, string sty) =>
    if not na(lvl)
        lqLines.push(line.new(t0, lvl, time, lvl, xloc = xloc.bar_time, color = lc, width = lqThick and n >= 2 ? 2 : 1, style = f_lstyle(sty)))
        // Tags: shorthand = flat 'BSL' / 'SSL' for major and minor alike ·
        // full = 'Major BSL' / 'Minor SSL'. No pool counts.
        string tag = lqShort ?
             (isHigh ? 'BSL' : 'SSL') :
             (isMaj ? 'Major ' : 'Minor ') + (isHigh ? 'BSL' : 'SSL')
        string kt = lqKeyTagOn ? f_lqKeyTag(lvl, isHigh) : ''   // v4.54
        tag := kt == '' ? tag : tag + ' · ' + kt
        lqLbls.push(label.new(time + timeframe.in_seconds() * 1000, lvl, tag, xloc = xloc.bar_time, style = label.style_label_left, size = f_szName(lqLblSz), color = COL_NONE, textcolor = lc))

// v4.54: the v4.15 key-level skip (and its v4.16 fall-through) is GONE - Ryan, 25/9/2026: "remove it
// entirely". A pool on PDH/PDL/PWH/PWL/PMH/PML now draws like any other and says so in its label
// (f_lqKeyTag). Selection is plain again: nearest major each side, then up to lqMinN minors inside it.
if barstate.islast
    while lqLines.size() > 0
        line.delete(lqLines.pop())
    while lqLbls.size() > 0
        label.delete(lqLbls.pop())
    if (lqBMajOn or lqBMinOn) and lqHiLvl.size() > 0
        int bMaj = -1
        int bMin = -1
        for i = 0 to lqHiLvl.size() - 1
            float lv = lqHiLvl.get(i)
            if lv > close
                if lqHiMaj.get(i)
                    if bMaj < 0 or lv < lqHiLvl.get(bMaj)
                        bMaj := i
                else if bMin < 0 or lv < lqHiLvl.get(bMin)
                    bMin := i
        if bMin >= 0 and bMaj >= 0 and math.abs(lqHiLvl.get(bMin) - lqHiLvl.get(bMaj)) <= lqTol
            bMin := -1
        // Hierarchy: Major BSL is the OUTER level — a minor sitting beyond the
        // major never draws; the minor is the stepping stone between price and
        // the major, not past it.
        if bMin >= 0 and bMaj >= 0 and lqHiLvl.get(bMin) > lqHiLvl.get(bMaj)
            bMin := -1
        if bMaj >= 0 and lqBMajOn
            f_lqDraw(lqHiLvl.get(bMaj), lqHiT.get(bMaj), lqHiN.get(bMaj), true, true, lqBMajCol, lqBMajSty)
        // v4.54: up to lqMinN minors, nearest first, capped inside the major.
        if bMin >= 0 and lqBMinOn
            float bCap = bMaj >= 0 ? lqHiLvl.get(bMaj) - lqTol : na
            float bLast = na
            for k = 1 to lqMinN
                int lqPick = -1
                for i = 0 to lqHiLvl.size() - 1
                    float lv = lqHiLvl.get(i)
                    if lv > close and not lqHiMaj.get(i) and (na(bLast) or lv > bLast) and (na(bCap) or lv < bCap)
                        if lqPick < 0 or lv < lqHiLvl.get(lqPick)
                            lqPick := i
                if lqPick < 0
                    break
                bLast := lqHiLvl.get(lqPick)
                f_lqDraw(lqHiLvl.get(lqPick), lqHiT.get(lqPick), lqHiN.get(lqPick), true, false, lqBMinCol, lqBMinSty)
    if (lqSMajOn or lqSMinOn) and lqLoLvl.size() > 0
        int sMaj = -1
        int sMin = -1
        for i = 0 to lqLoLvl.size() - 1
            float lv = lqLoLvl.get(i)
            if lv < close
                if lqLoMaj.get(i)
                    if sMaj < 0 or lv > lqLoLvl.get(sMaj)
                        sMaj := i
                else if sMin < 0 or lv > lqLoLvl.get(sMin)
                    sMin := i
        if sMin >= 0 and sMaj >= 0 and math.abs(lqLoLvl.get(sMin) - lqLoLvl.get(sMaj)) <= lqTol
            sMin := -1
        // Hierarchy: Major SSL is the OUTER level — a minor below the major
        // never draws.
        if sMin >= 0 and sMaj >= 0 and lqLoLvl.get(sMin) < lqLoLvl.get(sMaj)
            sMin := -1
        if sMaj >= 0 and lqSMajOn
            f_lqDraw(lqLoLvl.get(sMaj), lqLoT.get(sMaj), lqLoN.get(sMaj), false, true, lqSMajCol, lqSMajSty)
        // v4.54: up to lqMinN minors, nearest first, capped inside the major. Mirror of the BSL side.
        if sMin >= 0 and lqSMinOn
            float sCap = sMaj >= 0 ? lqLoLvl.get(sMaj) + lqTol : na
            float sLast = na
            for k = 1 to lqMinN
                int lqPick = -1
                for i = 0 to lqLoLvl.size() - 1
                    float lv = lqLoLvl.get(i)
                    if lv < close and not lqLoMaj.get(i) and (na(sLast) or lv < sLast) and (na(sCap) or lv > sCap)
                        if lqPick < 0 or lv > lqLoLvl.get(lqPick)
                            lqPick := i
                if lqPick < 0
                    break
                sLast := lqLoLvl.get(lqPick)
                f_lqDraw(lqLoLvl.get(lqPick), lqLoT.get(lqPick), lqLoN.get(lqPick), false, false, lqSMinCol, lqSMinSty)

// - - - - - - - - -
// Paint one ladder - COL_NONE hides it, in_sd_color brings it back. Every level line AND
// its number label move together, so a hidden ladder leaves no floating digits behind.
f_ladderColor(SdLadder sd, color col) =>
    if not na(sd) and not na(sd.lines) and sd.lines.size() > 0
        for k = 0 to sd.lines.size() - 1
            line.set_color(sd.lines.get(k), col)
        if not na(sd.lbls) and sd.lbls.size() > 0
            for k = 0 to sd.lbls.size() - 1
                label.set_textcolor(sd.lbls.get(k), col)

// STACKED CISD ZONE support. Collect one book's CONFIRMED setups into the scan rows.
// Also resets every done-pool tag AND SD-ladder drawing to its normal color first, so
// anything hidden by a zone that later dissolved (member failed or aged off) comes
// back on its own - hides are re-applied each bar only while the group still holds.
f_zoneRows(CisdBook bk, string htfTf, string ltfTf, bool en, array<float> lvls, array<int> dirs, array<int> c3s, array<int> x1s, array<int> x2s, array<string> names, array<string> pairs, array<label> lbs, array<int> secs, array<CisdBook> bks, array<int> dIdx) =>
    if en and not na(bk.cisd_done_lines) and bk.cisd_done_lines.size() > 0
        int htfMs = timeframe.in_seconds(htfTf) * 1000
        bool sdMap = not na(bk.cisd_done_sd) and bk.cisd_done_sd.size() == bk.cisd_done_lines.size()
        for i = 0 to bk.cisd_done_lines.size() - 1
            line ln = bk.cisd_done_lines.get(i)
            int dirv = bk.cisd_done_dir.get(i)
            label lb = bk.cisd_done_lbls.get(i)
            label.set_textcolor(lb, dirv == 1 ? in_cisd_bull : in_cisd_bear)
            if not na(bk.cisd_done_stack) and bk.cisd_done_stack.size() > i
                bk.cisd_done_stack.set(i, false)
            if sdMap
                f_ladderColor(bk.cisd_done_sd.get(i), in_sd_color)
            lvls.push(line.get_y1(ln))
            dirs.push(dirv)
            c3s.push(bk.cisd_done_c3end.get(i) - htfMs)
            x1s.push(line.get_x1(ln))
            x2s.push(line.get_x2(ln))
            names.push(f_tfName(ltfTf))
            pairs.push(f_tfName(htfTf) + '-' + f_tfName(ltfTf))
            lbs.push(lb)
            secs.push(timeframe.in_seconds(ltfTf))
            bks.push(bk)
            dIdx.push(i)

// Hide or restore one confirmed setup's SD ladder.
f_sdBlockColor(CisdBook bk, int di, color col) =>
    if not na(bk.cisd_done_sd) and bk.cisd_done_sd.size() > di
        f_ladderColor(bk.cisd_done_sd.get(di), col)

// The deepest SD level's price for one confirmed setup - the handoff trigger. na when that
// setup has no ladder (projections off), which simply means no handoff to run.
f_sdFinalLvl(CisdBook bk, int di) =>
    float out = na
    if not na(bk.cisd_done_sd) and bk.cisd_done_sd.size() > di
        SdLadder sd = bk.cisd_done_sd.get(di)
        if not na(sd) and not na(sd.lines) and sd.lines.size() > sd_deep_idx
            out := line.get_y1(sd.lines.get(sd_deep_idx))
    out
// - - - - - - - - -
// Main flow

bool use1 = in_s1_on and f_tfOk(in_s1_tf)
bool use2 = in_s2_on and f_tfOk(in_s2_tf)
bool use3 = in_s3_on and f_tfOk(in_s3_tf)

// Period rolls, one dedicated call site per slot (each keeps its own ta state)
bool r1 = f_newPeriod(in_s1_tf)
bool r2 = f_newPeriod(in_s2_tf)
bool r3 = f_newPeriod(in_s3_tf)

if use1
    f_roll(slot1, in_s1_tf, r1, in_s1_n)
    f_update(slot1, in_s1_tf)
if use2
    f_roll(slot2, in_s2_tf, r2, in_s2_n)
    f_update(slot2, in_s2_tf)
if use3
    f_roll(slot3, in_s3_tf, r3, in_s3_n)
    f_update(slot3, in_s3_tf)

// CISD hunts - three independent selectors, decoupled from the candle slots. Each
// self-gates on its enable flag and the chart-TF-vs-pair-LTF check inside HuntCISD.
bookA.HuntCISD(cisd_c2_a, cisd_ltf_a, cisd_en_a)
bookB.HuntCISD(cisd_c2_b, cisd_ltf_b, cisd_en_b)
bookC.HuntCISD(cisd_c2_c, cisd_ltf_c, cisd_en_c)

// v4.53: opposing-setup flag. Deliberately its own pass after all three hunts rather than
// inline in HuntCISD - the pool it reads only becomes trustworthy once that call's pruning
// chain has finished, and a marking layer belongs outside the engine that feeds it.
bookA.FlagOpposing(cisd_c2_a, cisd_ltf_a, cisd_en_a)
bookB.FlagOpposing(cisd_c2_b, cisd_ltf_b, cisd_en_b)
bookC.FlagOpposing(cisd_c2_c, cisd_ltf_c, cisd_en_c)

// - - - - - - - - -
// CISD FVG. A MARKING LAYER. Read this before changing anything below it: nothing in
// this block feeds the model. It reads the confirmed pool, draws boxes, and stops. No
// signal, no gate, no state that anything else consumes. Ryan's law, verbatim: "THIS IS NOT
// CHANGING ANYTHING TO DO WITH THE MODEL. Just simply scanning for FVGs after the CISD."
// If a future edit makes this block influence a setup, that edit is wrong.
//
// WHAT IT ANSWERS. A confirmed CISD often never gets retested and the gap above it is why -
// price does not return to a level while unfilled imbalance is holding it up. Marking those
// gaps puts that in front of the trader while it is happening. The read stays theirs.
//
// Scans the CISD's OWN timeframe, not the chart TF (Ryan's call): the gap belongs to the
// displacement leg the CISD created, so it is a property of that leg's timeframe. Chart TF
// must be at or below it - the same guard HuntCISD already self-applies.
//
// Placed directly after the three HuntCISD calls for exactly the reason LRLR is: each call
// has already run InvalidateConfirmed -> CapSetups -> CheckRetests, so the confirmed pool
// read here is this bar's truth with dead setups already out of it. That is what retires
// the boxes - no live setup, no layer, and it costs no extra bookkeeping.

// Non-repaint idiom A, identical to the HTF FVG block above: the completed 3-candle pattern
// sits at [3][2][1] off the developing LTF bar, and the read fires on that bar's TIME
// changing rather than on a flag's rising edge. One evaluation per closed bar, on finished
// data. See the HTF FVG comment for the two ways the old timing dropped gaps.
[pcA_bl, pcA_bT, pcA_bB, pcA_br, pcA_sT, pcA_sB, pcA_t] = request.security(syminfo.tickerid, cisd_ltf_a, [low[1] > high[3] and close[2] > open[2], low[1], high[3], high[1] < low[3] and close[2] < open[2], low[3], high[1], time[1]], lookahead = barmerge.lookahead_on)
[pcB_bl, pcB_bT, pcB_bB, pcB_br, pcB_sT, pcB_sB, pcB_t] = request.security(syminfo.tickerid, cisd_ltf_b, [low[1] > high[3] and close[2] > open[2], low[1], high[3], high[1] < low[3] and close[2] < open[2], low[3], high[1], time[1]], lookahead = barmerge.lookahead_on)
[pcC_bl, pcC_bT, pcC_bB, pcC_br, pcC_sT, pcC_sB, pcC_t] = request.security(syminfo.tickerid, cisd_ltf_c, [low[1] > high[3] and close[2] > open[2], low[1], high[3], high[1] < low[3] and close[2] < open[2], low[3], high[1], time[1]], lookahead = barmerge.lookahead_on)

bool pcA_new = not na(pcA_t) and pcA_t != nz(pcA_t[1], -1)
bool pcB_new = not na(pcB_t) and pcB_t != nz(pcB_t[1], -1)
bool pcC_new = not na(pcC_t) and pcC_t != nz(pcC_t[1], -1)

// v4.44: pcDirs deleted. It stored a direction nothing read, and the value was already
// recoverable twice over - slot encodes it (bSlot = who*2, sSlot = who*2+1, so even is
// bull and odd is bear) and pcOwner stores slot. Two columns, one truth.
var array<box> pcBoxes = array.new<box>()
var array<int> pcOwner = array.new<int>()

// Per-selector generation tracking. pcGenId holds the identity of the setup currently being
// scanned (its CISD line's x1 - the level's origin time, which never moves), pcFrom the bar
// time the scan window opened.
// v6 footgun this sidesteps: `na != na` evaluates to na, not false, and `na != 5` is also
// na. Comparing these as raw na ints would silently fail to notice a setup DYING, which is
// the exact transition that has to retire the boxes. -1 as the "none" sentinel keeps every
// comparison plain integer arithmetic. Bar times are large positives, so it can't collide.
// v4.40: SIX slots, not three - selector x DIRECTION. slot = who * 2 + (bull ? 0 : 1).
// v4.39 and earlier kept one slot per selector and read "the live setup" as the NEWEST entry
// in the confirmed pool, either direction. That is a broken proxy and it is what rejected
// Ryan's gap: at 10:25 the newest pool entry was still the BEAR setup from the 09:30-10:15
// decline, so a textbook BISI was tested against a bear setup and thrown out as "wrong
// direction" - while the bull CISD at 29,630.75 that actually owned it sat further back in
// the pool. The engine itself tracks bull and bear independently (cisd_bull_line /
// cisd_bear_line); the layer reading it has to do the same. A BISI is now always tested
// against the newest BULL setup and a SIBI against the newest BEAR one, so the two can
// never interfere and one direction confirming can never blind the other.
var array<int> pcGenId = array.new<int>(6, -1)
var array<int> pcFrom  = array.new<int>(6, -1)
// One-shot latch per slot: 1 once that setup's gap has been drawn. The scan closes there and
// does not reopen - a later gap neither replaces it nor stacks on it, and a fill does not
// re-arm it. That latch IS the "nothing else" rule (v4.36).
var array<int> pcHave  = array.new<int>(6, 0)

// v4.41: ROLLING CANDIDATE BUFFER. Every gap the LTF read produces is recorded here the
// moment it is seen, whether or not any setup exists yet. This is what breaks the race that
// four versions of window-nudging could not: a gap is detected 2 bars after its own c2, and
// the setup created by that same displacement candle does not appear in the confirmed pool
// until its own read lands - so the gap routinely arrives BEFORE its owner. Requiring the
// owner to already be there was always going to drop it.
// Now the setup reaches back and CLAIMS its gap on confirm instead. Order is chronological
// (push order), so the first match walking forward is the earliest qualifying gap.
var array<float> pcQTop  = array.new<float>()
var array<float> pcQBot  = array.new<float>()
var array<int>   pcQTime = array.new<int>()
var array<int>   pcQLeftT = array.new<int>()
var array<int>   pcQSlot = array.new<int>()
int PC_Q_CAP = 240

// v4.38 DEBUG POOL. Only ever populated when pcDbg is on, and it draws candidates the live
// layer REJECTED - that is the entire point, so it must not share the live pools.

// No eviction cap is needed: one box per selector, three selectors, three boxes maximum.
// Against the script's 500-box ceiling (HTF FVGs 40 x 2, candle lanes 3 x 25 bodies plus
// imbalances, stacked-zone pool) this layer is a rounding error.

// Takes a SLOT (selector x direction), not a selector - clearing a bull setup's box must
// never take the bear setup's box with it.
f_pcClear(int slot) =>
    if pcBoxes.size() > 0
        for i = pcBoxes.size() - 1 to 0
            if pcOwner.get(i) == slot
                box.delete(pcBoxes.get(i))
                box _pb = pcBoxes.remove(i)
                int _po = pcOwner.remove(i)

// Left anchor = c1 of the pattern, 3 LTF bars back under idiom A. Clamped to the same
// ~10k drawing-reference limit as f_hfLeft. The int() cast is required - v6 int/int
// division returns a float.

// Returns whether it actually drew, so the caller only latches the one-shot on a real box.
// v4.42: boxes are xloc.bar_time, not bar_index. Ryan: "FVG only goes as far as CISD." The
// right edge has to track the owning CISD LINE's endpoint, and lines in this file are drawn
// in bar_time - so the box has to speak the same coordinate system or the two can never be
// made to agree. leftT is passed in rather than computed, because a gap CLAIMED out of the
// buffer must anchor where it actually formed, not where the claim happened.
f_pcAdd(float top, float bot, int slot, int leftT, int rightT, string tf, color col) =>
    bool drew = not na(top) and not na(bot) and top > bot and not na(leftT) and not na(rightT)
    if drew
        // v4.42: tag on the RIGHT edge, Ryan's call - it reads as the label of a zone you are
        // looking back at, and it keeps the text off the candles that formed the gap.
        pcBoxes.push(box.new(leftT, top, int(math.max(rightT, leftT)), bot, xloc = xloc.bar_time, bgcolor = col, border_color = COL_NONE, text = f_tfName(tf), text_size = size.tiny, text_halign = text.align_right, text_color = pcTxtCol))
        pcOwner.push(slot)
    drew

f_pcQPush(int slot, float top, float bot, int t, string tf) =>
    if not na(top) and not na(bot) and top > bot and not na(t)
        pcQTop.push(top)
        pcQBot.push(bot)
        pcQTime.push(t)
        // t is c3's open under idiom A, so c1 - the gap's left edge - is two LTF bars back.
        pcQLeftT.push(t - 2 * timeframe.in_seconds(tf) * 1000)
        pcQSlot.push(slot)
        if pcQTop.size() > PC_Q_CAP
            float _qa = pcQTop.shift()
            float _qb = pcQBot.shift()
            int   _qc = pcQTime.shift()
            int   _qd = pcQLeftT.shift()
            int   _qe = pcQSlot.shift()

// A freshly confirmed setup claims its gap. Walks the buffer FORWARD, so the first match is
// the earliest qualifying gap - which is the adjacent one, because the leg starts at the
// level. Returns whether it drew.
f_pcClaim(int slot, int dir, float lvl, int fromT, int rightT, string tf, color col) =>
    bool got = false
    if pcQTop.size() > 0
        for i = 0 to pcQTop.size() - 1
            if not got and pcQSlot.get(i) == slot and pcQTime.get(i) >= fromT
                float qt = pcQTop.get(i)
                float qb = pcQBot.get(i)
                if (dir == 1 and qt > lvl) or (dir == -1 and qb < lvl)
                    if f_pcAdd(qt, qb, slot, pcQLeftT.get(i), rightT, tf, col)
                        got := true
    got

// v4.42: hold every box in a slot to its CISD line's right edge. Runs each bar because the
// line grows while its setup is live and stops when the setup stops - the box inherits that
// for free, so the gap never outlives the level it belongs to.
f_pcRight(int slot, int x2) =>
    if pcBoxes.size() > 0 and not na(x2)
        for i = 0 to pcBoxes.size() - 1
            if pcOwner.get(i) == slot
                // int() cast: math.max is not guaranteed to hand back an int, and bar_time
                // coordinates must be int.
                box.set_right(pcBoxes.get(i), int(math.max(x2, box.get_left(pcBoxes.get(i)))))

// Debug marker: dashed outline where a candidate sat, tagged with its verdict. Oldest-first
// eviction so a long replay keeps the recent picture rather than the opening minutes.
// v4.39: the y offset per selector is not cosmetic. Two selectors set to the SAME LTF
// produce identical candidates at identical coordinates, and the last one drawn covers the
// others - which is exactly how v4.38 hid the verdict that mattered behind an S2 label.
// Staggering by a fraction of the gap's own height keeps them separate at any scale.

// Generation handling for ONE slot. A different owning setup, or none left at all: either
// way the previous setup's gap has stopped describing the current picture and comes off, and
// the one-shot re-arms for whatever owns the slot now. The "none left" case is how a dead
// setup takes its box with it - InvalidateConfirmed and CapSetups have already pruned the
// pool by the time this runs, so an id of -1 genuinely means "gone".
//
// WHERE THE WINDOW OPENS (v4.41). The level's OWN ORIGIN - the CISD line's x1, where the
// level was captured. No tuned offset, nothing to drift across symbols or timeframes. The
// "3 bars back" constant of v4.37 existed only to chase the confirm read's lag, and the
// buffer makes that unnecessary: the setup now reaches back for its gap rather than needing
// the window to happen to cover it.
// Deliberately NOT the line's x2: that endpoint gets rewritten later by the no-intrusion
// capping, and a window whose start can move is one that can retroactively adopt gaps.
//
// On a new owner the slot clears, re-arms, and immediately CLAIMS from the buffer - which is
// what catches the gap created by the confirming candle itself, the one that was always
// arriving before its setup existed.
f_pcGen(int slot, int id, int dir, float lvl, int x2, bool en, string tf, color col) =>
    if id != pcGenId.get(slot)
        f_pcClear(slot)
        pcGenId.set(slot, id)
        pcHave.set(slot, 0)
        pcFrom.set(slot, id)
        if id != -1 and pcOn and en and not na(lvl)
            if f_pcClaim(slot, dir, lvl, id, x2, tf, col)
                pcHave.set(slot, 1)
    f_pcRight(slot, x2)

// One selector's pass, evaluated independently per direction.
f_pcRun(int who, CisdBook cs, bool en, string ltf, bool isNew, bool gotBull, float bT, float bB, bool gotBear, float sT, float sB, int ltime) =>
    // v4.40: newest confirmed setup PER DIRECTION, not "newest overall". Walk newest-first
    // and take the first hit in each direction - pushes append, removals preserve order, and
    // anything dead is already pruned. Reading one live setup for both directions is what
    // rejected Ryan's BISI as "wrong direction" while a bear setup happened to be the most
    // recent thing in the pool.
    int   bullId  = -1
    int   bearId  = -1
    float bullLvl = na
    float bearLvl = na
    int   bullX2  = na
    int   bearX2  = na
    if not na(cs.cisd_done_lines) and cs.cisd_done_lines.size() > 0 and not na(cs.cisd_done_dir) and cs.cisd_done_dir.size() == cs.cisd_done_lines.size()
        for i = cs.cisd_done_lines.size() - 1 to 0
            int d = cs.cisd_done_dir.get(i)
            if d == 1 and bullId == -1
                bullId  := nz(line.get_x1(cs.cisd_done_lines.get(i)), -1)
                // Level read the same way CheckRetests reads it, so the two can never
                // disagree about where this CISD sits.
                bullLvl := line.get_y1(cs.cisd_done_lines.get(i))
                // v4.42: the line's right edge is the box's right edge. Read it here, in the
                // one place that already resolves which line owns this slot.
                bullX2  := line.get_x2(cs.cisd_done_lines.get(i))
            if d == -1 and bearId == -1
                bearId  := nz(line.get_x1(cs.cisd_done_lines.get(i)), -1)
                bearLvl := line.get_y1(cs.cisd_done_lines.get(i))
                bearX2  := line.get_x2(cs.cisd_done_lines.get(i))

    int bSlot = who * 2
    int sSlot = who * 2 + 1
    bool tfOk = timeframe.in_seconds(ltf) >= timeframe.in_seconds()

    // Buffer FIRST, before the generation check, so a gap detected on the same bar its setup
    // confirms is already available to be claimed. This ordering is the whole fix.
    if en and isNew and tfOk
        if gotBull
            f_pcQPush(bSlot, bT, bB, ltime, ltf)
        if gotBear
            f_pcQPush(sSlot, sT, sB, ltime, ltf)

    f_pcGen(bSlot, bullId, 1, bullLvl, bullX2, en, ltf, pcBull)
    f_pcGen(sSlot, bearId, -1, bearLvl, bearX2, en, ltf, pcBear)

    bool bArmed = pcOn and en and bullId != -1 and not na(bullLvl) and pcFrom.get(bSlot) != -1 and tfOk
    bool sArmed = pcOn and en and bearId != -1 and not na(bearLvl) and pcFrom.get(sSlot) != -1 and tfOk

    // DEBUG (v4.38). Runs BEFORE the live gate so the state it reports is the state the gate
    // is about to see - in particular the latch, which the live block may set a line later.
    // Every gap the detector produces is marked and the verdict names the FIRST gate that
    // rejected it, so a gap that should have drawn and did not says why.
    //
    // The state readout is written EVERY bar, not only when a gap is seen: "the detector
    // never fired at all" is itself a possible answer, and it is invisible in a readout that
    // only updates on a detection.
    // v4.39: DISABLED selectors are not marked at all. f_selLtf falls through to
    // timeframe.period for any unmatched value INCLUDING 'Off', so an Off selector still
    // resolves its LTF to the chart TF and its security read still returns real gaps. Those
    // candidates are phantoms - that selector can never draw - and in v4.38 they stacked on
    // top of the live selector's verdicts and hid them completely. The table still reports
    // every selector's state, which is where an off selector belongs.

    // v4.36 - Ryan's law: the ONLY gap this layer wants is the one sitting ON the level.
    // That is the gap that explains a missed retest; one 150 points up the leg explains
    // nothing about the CISD and is just a gap, which MM Gaps and FVG Finder already own.
    //
    // Two tests, and between them they say "the FVG above or below our CISD, nothing else".
    // FIRST: the one-shot means this is the earliest qualifying gap after the confirm, and
    // the earliest one is the adjacent one, because the leg starts at the level. SIDE: it
    // must actually reach past the level in that setup's direction.
    //
    // The side test is on the FAR edge (bull top, bear bottom), not the near edge, on
    // purpose - a gap that STRADDLES the level survives it. A CISD sitting inside its own
    // gap is the strongest version of this pattern, not a disqualifying one. Adjacency is
    // enforced by taking the first, never by a distance cap, so this travels across symbols
    // and timeframes without a tuned constant to drift.
    // The live path, for a gap that forms while the setup is already running. The claim path
    // in f_pcGen covers the other case - a gap that arrived before its setup existed.
    int liveLeftT = na(ltime) ? int(na) : ltime - 2 * timeframe.in_seconds(ltf) * 1000
    if bArmed and gotBull and pcHave.get(bSlot) == 0 and isNew and not na(ltime) and ltime >= pcFrom.get(bSlot) and not na(bT) and bT > bullLvl
        if f_pcAdd(bT, bB, bSlot, liveLeftT, bullX2, ltf, pcBull)
            pcHave.set(bSlot, 1)
    if sArmed and gotBear and pcHave.get(sSlot) == 0 and isNew and not na(ltime) and ltime >= pcFrom.get(sSlot) and not na(sB) and sB < bearLvl
        if f_pcAdd(sT, sB, sSlot, liveLeftT, bearX2, ltf, pcBear)
            pcHave.set(sSlot, 1)

f_pcRun(0, bookA, cisd_en_a, cisd_ltf_a, pcA_new, pcA_bl, pcA_bT, pcA_bB, pcA_br, pcA_sT, pcA_sB, pcA_t)
f_pcRun(1, bookB, cisd_en_b, cisd_ltf_b, pcB_new, pcB_bl, pcB_bT, pcB_bB, pcB_br, pcB_sT, pcB_sB, pcB_t)
f_pcRun(2, bookC, cisd_en_c, cisd_ltf_c, pcC_new, pcC_bl, pcC_bT, pcC_bB, pcC_br, pcC_sT, pcC_sB, pcC_t)

// v4.38: one readout for all three selectors, redrawn on the last bar only. Answers the
// questions a chart full of boxes cannot: is the selector even on, did the detector fire at
// all ("gaps seen"), where did the window open, and is the one-shot already spent.
// v4.39: a TABLE, not a label. The v4.38 readout was anchored at (bar_index, high) on the
// last bar, which puts it off the right edge or behind the price scale as often as not - so
// the one artefact that could have named the live selector was never actually visible. A
// table is pinned to the pane and cannot be scrolled away from.

// No maintenance pass here any more. v4.42 moved the right edge into f_pcRight, called from
// f_pcGen, because the edge is now the owning CISD LINE's endpoint rather than the current
// bar - and that line is only resolvable inside f_pcRun, where the slot's owner is known.
//
// v4.41 - Ryan's law: "we want this FVG to stick with the setup so once it prints it stays
// until entire setup disappears." The fill test that used to live here was inherited from
// the HTF FVG boxes and is wrong for this layer: a gap getting tapped and HOLDING is the
// single most informative thing this tool can show - it is the reason the level underneath
// never got retested - and deleting the box on that tap erased exactly the case worth
// seeing. These boxes die one way only: their setup leaves the confirmed pool, which
// f_pcClear handles on the generation change.

// - - - - - - - - -
// LRLR — LOW RESISTANCE LIQUIDITY RUN. Ported from MM LRLR v1.1 (same engine, same
// calibration). A staircase of unswept swing extremes: bearish = lower lows AND lower
// highs, and each of those lower highs is a failure swing left unswept. Four or more
// stacked is a ladder of resting liquidity along one diagonal. Bullish is the mirror.
//
// WHY IT LIVES HERE AND NOT ON ITS OWN. Ryan's rule: no LRLR prints unless a SETUP is
// present. On its own the tool answers "is there a clean ladder somewhere" - a question
// nobody asked. Behind the setup gate it answers the one that matters: the 4h C2 sealed,
// the 15m CISD confirmed, the SD ladder is projecting up - is the road to those targets
// actually clean? An LRLR sitting on the draw is a low-resistance path to the target. No
// setup, no question, no line.
//
// Detection runs CONTINUOUSLY and only VISIBILITY is gated. A staircase usually forms
// well before the setup that makes it relevant, so gating detection would mean the run
// simply would not exist at the moment you needed it.
//
// Placed directly after the three HuntCISD calls: each one has already run
// InvalidateConfirmed -> CapSetups -> CheckRetests internally, so the confirmed pools
// read here are this bar's truth, with dead setups already out of them.

float lrAtr = ta.atr(14)

// ═══ LOCKED CALIBRATION (v4.32) — constants, deliberately NOT inputs ═══
// Ryan's call: a PRO tool does the work. These were tuned against 1m/5m NQ and every one
// that could drift is ATR-scaled, so they travel to other symbols and timeframes on their
// own. There is nothing here a user improves by guessing at it, and a TradingView "reset
// settings" can never undo the tuning. The full set stays exposed as inputs in the
// standalone MM LRLR — that is the place to experiment, not a live PRO chart.
int   lrPiv   = 3      // pivot half-width. A real run spans 30-50 bars on 1m; above ~5
                       // there is no room for four points inside that window.
int   lrPts   = 4      // Ryan's law: FOUR rungs on the line. Two swings is a single
                       // failure swing, not a run - the mistake every other LRLR makes.
float lrLeg   = 0.75   // min swing depth. Kills the wiggles INSIDE an impulse, which
                       // sequence perfectly and hold nobody's stops.
float lrScat  = 1.0    // max rung scatter off the fitted line. Rejects spike-anchored
                       // runs, whose line floats away from the candles through the middle.
float lrSlope = 0.6    // max ATR/bar. An LRLR is a grind that LEAVES liquidity behind it;
                       // a near-vertical leg is displacement, travelling the same way but
                       // leaving nothing resting.
float lrHover = 0.15   // v4.34: was 0.3, was 0.5 at v4.32. Ryan asked twice for the line
                       // closer. Floor is roughly 0.1 - below that the line starts sitting
                       // ON the rungs, and a hovering line that touches its own points is
                       // the one thing this drawing must never do.
int   lrExt   = 5      // bars of overhang past the newest rung.
int   lrMax   = 4      // runs retained at once, collected ones included.
int   lrFade  = 80     // HRLR transparency once a run is collected.

// ── Zigzag backbone ──
// Folds the raw pivot stream into an ALTERNATING high-low-high-low skeleton: two pivots
// of the same side in a row means the more extreme absorbs the other. On that skeleton
// the whole staircase test collapses to one comparison - every point must undercut
// (bearish) or exceed (bullish) the point TWO places back. Stepping the index by one
// alternates between checking the highs and the lows, so a single walk verifies both.
var array<float> lrZzV = array.new<float>()
var array<int>   lrZzB = array.new<int>()
var array<bool>  lrZzH = array.new<bool>()

int   LR_ZZ_CAP    = 60
float LR_SLOPE_EPS = 1e-9
int   LR_BI_CLAMP  = MAX_BACK   // v4.55: was 9500, past the 5,000 history buffer
float LR_OUTLIER_K = 1.2   // v4.32: was 1.5, trimmed with the hover gap to bring the line
                           // in closer. Hover clears 1.2 sd of the residuals, NOT the
                           // furthest point.
                           // Least-squares residuals sum to zero, so this is ~0 on a tight
                           // ladder (line sits exactly the hover gap off the rungs) while a
                           // lone outlier can no longer drag the whole line off structure.

f_lrPush(bool isHigh, float val, int b, float minLeg) =>
    int n = lrZzV.size()
    if n == 0
        lrZzV.push(val)
        lrZzB.push(b)
        lrZzH.push(isHigh)
    else if lrZzH.get(n - 1) == isHigh
        if (isHigh and val > lrZzV.get(n - 1)) or (not isHigh and val < lrZzV.get(n - 1))
            lrZzV.set(n - 1, val)
            lrZzB.set(n - 1, b)
    else
        // LEG-DEPTH GATE. An opposing pivot only becomes a rung if the leg into it has
        // real depth - otherwise a vertical impulse hands over a handful of tiny pullbacks
        // that sequence perfectly and draw a staircase across a move that never
        // staircased. A rejected pivot is dropped and the next same-side pivot absorbs
        // into the rung above, which is right: it was all one leg.
        if math.abs(val - lrZzV.get(n - 1)) >= minLeg
            lrZzV.push(val)
            lrZzB.push(b)
            lrZzH.push(isHigh)
    // Trimmed OUTSIDE the branch chain - .shift() returns the dropped element, and leaving
    // it as a branch's last statement makes that branch evaluate to a value while its
    // siblings are void (CE10235).
    if lrZzV.size() > LR_ZZ_CAP
        float _v = lrZzV.shift()
        int   _b = lrZzB.shift()
        bool  _h = lrZzH.shift()
    true

float lrPvH = ta.pivothigh(high, lrPiv, lrPiv)
float lrPvL = ta.pivotlow(low, lrPiv, lrPiv)
float lrLegMin = lrLeg * nz(lrAtr, 0.0)
if lrOn and not na(lrPvH)
    f_lrPush(true, lrPvH, bar_index - lrPiv, lrLegMin)
if lrOn and not na(lrPvL)
    f_lrPush(false, lrPvL, bar_index - lrPiv, lrLegMin)

// ── Run state ──
type LrRun
    line  ln
    label lb
    int   dir        // 1 = bullish (line under the higher lows), -1 = bearish (over the highs)
    int   originBar  // first rung's bar - also the run's identity (same origin = same run)
    float originVal  // first rung's price - the last one price has to clear to spend the run
    bool  collected

var array<LrRun> lrRuns = array.new<LrRun>()

// Fitted in BAR-INDEX space, not time: a straight line in time space bends against the
// candles across session gaps, which is exactly where a hovering line looks broken.
// Returns the scatter alongside the fit so the quality gate can read it back.
f_lrFit(array<int> xs, array<float> ys, int dir, float gap) =>
    int   n  = xs.size()
    float sx = 0.0
    float sy = 0.0
    for i = 0 to n - 1
        sx += xs.get(i)
        sy += ys.get(i)
    float mx  = sx / n
    float my  = sy / n
    float num = 0.0
    float den = 0.0
    for i = 0 to n - 1
        float dx = xs.get(i) - mx
        num += dx * (ys.get(i) - my)
        den += dx * dx
    float slope = math.abs(den) < LR_SLOPE_EPS ? 0.0 : num / den
    float icept = my - slope * mx
    float ss = 0.0
    for i = 0 to n - 1
        float r = ys.get(i) - (slope * xs.get(i) + icept)
        ss += r * r
    float sd  = math.sqrt(ss / n)
    float off = LR_OUTLIER_K * sd + gap
    [slope, icept + (dir == -1 ? off : -off), sd]

// Walk back from the newest rung while the staircase holds, then count the LINE side.
// A flag rather than `break`: an if/else where one arm assigns and the other breaks gives
// the arms different value types, which Pine rejects.
f_lrScan(int dir) =>
    int n = lrZzV.size()
    int start = n - 1
    bool broke = false
    if n >= 3
        for i = n - 1 to 2
            if not broke
                if dir == -1 ? lrZzV.get(i) < lrZzV.get(i - 2) : lrZzV.get(i) > lrZzV.get(i - 2)
                    start := i - 2
                else
                    broke := true
    bool wantHi = dir == -1
    int  pts = 0
    // Guard on start, not n: below 3 points the walk never ran and start is negative, and
    // Pine runs `for 0 to -1` once - straight into a get(-1) abort.
    if start >= 0
        for i = start to n - 1
            if lrZzH.get(i) == wantHi
                pts += 1
    [start, pts]

// Flat accumulator return rather than nested if/else values - keeps every branch void.
f_lrApply(int dir, color col) =>
    if lrOn and lrZzV.size() >= 3 and not na(lrAtr) and lrAtr > 0
        [start, pts] = f_lrScan(dir)
        if pts >= lrPts
            array<int>   xs = array.new<int>()
            array<float> ys = array.new<float>()
            bool wantHi = dir == -1
            for i = start to lrZzV.size() - 1
                if lrZzH.get(i) == wantHi
                    xs.push(lrZzB.get(i))
                    ys.push(lrZzV.get(i))
            int   oBar = xs.get(0)
            float oVal = ys.get(0)
            [slope, icept, scat] = f_lrFit(xs, ys, dir, lrHover * lrAtr)
            // SCATTER rejects a run whose rungs sit too far off their own fitted line - the
            // spike-anchored ones, whose line floats away from the candles. STEEPNESS
            // rejects near-vertical runs: an LRLR is a grind that LEAVES liquidity behind
            // it, and a displacement leg is a different animal going the same way.
            bool okScat  = lrScat <= 0 or scat <= lrScat * lrAtr
            bool okSlope = lrSlope <= 0 or math.abs(slope) <= lrSlope * lrAtr
            if okScat and okSlope
                int   x1 = int(math.max(bar_index - LR_BI_CLAMP, oBar))
                int   x2 = xs.get(xs.size() - 1) + lrExt
                float y1 = slope * x1 + icept
                float y2 = slope * x2 + icept
                LrRun cur = na
                if lrRuns.size() > 0
                    for i = lrRuns.size() - 1 to 0
                        LrRun r = lrRuns.get(i)
                        if r.dir == dir and r.originBar == oBar and not r.collected
                            cur := r
                            break
                if na(cur)
                    // Born invisible. The visibility pass below is the ONLY thing that
                    // colours a run, so a line can never appear ahead of its setup.
                    line  nl = line.new(x1, y1, x2, y2, xloc = xloc.bar_index, color = color.new(col, 100), width = lrWidth, style = f_lstyle(lrStyle))
                    label nb = label.new(x2, y2, '', xloc = xloc.bar_index, style = label.style_label_down, color = COL_NONE, textcolor = color.new(col, 100), size = size.tiny)
                    lrRuns.push(LrRun.new(nl, nb, dir, oBar, oVal, false))
                    if lrRuns.size() > lrMax
                        LrRun old = lrRuns.shift()
                        line.delete(old.ln)
                        label.delete(old.lb)
                else
                    line.set_xy1(cur.ln, x1, y1)
                    line.set_xy2(cur.ln, x2, y2)
    true

if barstate.isconfirmed
    f_lrApply(-1, lrBear)
    f_lrApply(1, lrBull)

// ── Collection: LRLR → HRLR ──
// A collected run is DEMOTED, not deleted. Those extremes are swept now, which makes them
// high-resistance liquidity: stop territory, not target territory. The trigger is the
// ORIGIN rung, the furthest one - price works a ladder from the near end inward, so
// nothing short of the origin means the whole thing has been cleared. Wick counts: resting
// liquidity is taken by the trade, not by the close.
if lrRuns.size() > 0
    for i = 0 to lrRuns.size() - 1
        LrRun r = lrRuns.get(i)
        if not r.collected and ((r.dir == -1 and high >= r.originVal) or (r.dir == 1 and low <= r.originVal))
            r.collected := true

// ── Setup gate ──
// A setup is LIVE when a CISD has CONFIRMED and not yet died: exactly the population of
// each book's done pool, which InvalidateConfirmed and CapSetups have already pruned.
f_lrBookDirs(CisdBook cs) =>
    bool bl = false
    bool br = false
    if not na(cs.cisd_done_dir) and cs.cisd_done_dir.size() > 0
        for i = 0 to cs.cisd_done_dir.size() - 1
            if cs.cisd_done_dir.get(i) == 1
                bl := true
            else
                br := true
    [bl, br]

[lrBlA, lrBrA] = f_lrBookDirs(bookA)
[lrBlB, lrBrB] = f_lrBookDirs(bookB)
[lrBlC, lrBrC] = f_lrBookDirs(bookC)
bool lrBullSetup = lrBlA or lrBlB or lrBlC
bool lrBearSetup = lrBrA or lrBrB or lrBrC
bool lrAnySetup  = lrBullSetup or lrBearSetup

// Ryan's law: an LRLR IS liquidity, and a setup is an attempt to RAID it. So the gate is
// the ladder's POSITION relative to price, not its direction label - a setup drawing UP
// wants the ladder ABOVE, because that is the liquidity it is reaching for.
//
// For an UNCOLLECTED run the two are the same thing by construction: an unswept bearish
// ladder is above price, or it would already have been collected. They diverge the moment
// a run is collected, and there position is the one that stays honest - a swept ladder now
// sitting BELOW a bull setup is stop reference, which is precisely what HRLR means. Gating
// on direction would have pinned that HRLR to the wrong side of the trade.
//
// Measured off the ORIGIN rung: it is the run's defining extreme and the same level that
// decides collection, so position and lifecycle can never disagree about a run.

// ── Visibility. State via transparency of ONE base colour, per house convention: the
// fill IS the state signal. Hidden = 100, live = solid, collected = the HRLR fade.
if lrRuns.size() > 0
    for i = 0 to lrRuns.size() - 1
        LrRun r = lrRuns.get(i)
        bool  abv = r.originVal > close
        bool  vis = lrGate == 'Always' ? true : lrGate == 'Any live setup' ? lrAnySetup : abv ? lrBullSetup : lrBearSetup
        color bse = r.dir == -1 ? lrBear : lrBull
        color eff = color.new(bse, not (lrOn and vis) ? 100 : r.collected ? lrFade : 0)
        line.set_color(r.ln, eff)
        label.set_textcolor(r.lb, eff)
        label.set_text(r.lb, not (lrOn and vis) ? '' : r.collected ? 'HRLR' : 'LRLR')
        // v4.33: user-placed tag. Read straight off the line's own endpoints so it tracks
        // a run that is still growing another rung, and so position stays a pure function
        // of the line - no drawing coordinate is ever derived from another drawing's tag.
        // label_down hangs the body ABOVE its anchor and label_up hangs it BELOW; with a
        // transparent background the pointer never shows, so all you see is the text
        // sitting clear of the line on the chosen side.
        int   lx1 = int(line.get_x1(r.ln))
        int   lx2 = int(line.get_x2(r.ln))
        float ly1 = line.get_y1(r.ln)
        float ly2 = line.get_y2(r.ln)
        // v4.55: a run's line keeps its original x1 while bar_index moves on, so an old run
        // drifts past the history buffer and this per-bar set_xy became a far-back read
        // (the RE10143 crash). Pin the label's x to MAX_BACK and slide y along the line to
        // match, so the tag stays ON the line instead of floating off it.
        if lx1 < bar_index - MAX_BACK
            int   cx  = bar_index - MAX_BACK
            float slp = lx2 != lx1 ? (ly2 - ly1) / (lx2 - lx1) : 0.0
            ly1 := ly1 + slp * (cx - lx1)
            lx1 := cx
        if lx2 < bar_index - MAX_BACK
            lx2 := lx1
            ly2 := ly1
        label.set_xy(r.lb, lrLblH == 'Left' ? lx1 : lrLblH == 'Center' ? int(math.avg(lx1, lx2)) : lx2, lrLblH == 'Left' ? ly1 : lrLblH == 'Center' ? math.avg(ly1, ly2) : ly2)
        // v4.34: per-direction tag side. r.dir 1 = bullish run, -1 = bearish.
        label.set_style(r.lb, (r.dir == 1 ? lrLblBull : lrLblBear) == 'Above' ? label.style_label_down : label.style_label_up)

// - - - - - - - - -
// STACKED CISD ZONES. When two or three timeframe pairs print their C2 on the SAME
// close (e.g. the 15m and 1h candles sealing on the hour together) and EVERY one of
// those CISDs has CONFIRMED, the space between the confirmed levels becomes one
// zone: the level lines stay - they ARE the zone's exact edges - the fill joins
// them, the per-line tags collapse into one combined tag, and one STACKED alert
// fires when the LAST member confirms. Until every member confirms, nothing
// changes. A member that fails (FC2) or ages off leaves its done pool, the group
// breaks, and the fill dissolves on the next pass while the surviving line and all
// FC2/retest logic keep running untouched underneath.
//
// SD LADDER HANDOFF: a stack shows ONE ladder at a time, starting with the LOWEST
// pair TF's (the closest targets). The moment price trades through that ladder's
// deepest level, the stage advances (latched - a retrace never flips it back): the
// lower ladder hides and the next higher TF's ladder shows. Cascades across a
// triple stack; past the last ladder there is nothing left to hand off to.
var array<box> zoneBoxes = array.new<box>()
var array<label> zoneLbls = array.new<label>()
var array<string> zoneSeen = array.new<string>()
var array<string> zoneStageKey = array.new<string>()
var array<int> zoneStageVal = array.new<int>()
var array<string> zoneDead = array.new<string>()

if in_zone_on
    array<float> zl = array.new<float>()
    array<int> zd = array.new<int>()
    array<int> zc = array.new<int>()
    array<int> zx1 = array.new<int>()
    array<int> zx2 = array.new<int>()
    array<string> zn = array.new<string>()
    array<string> zp = array.new<string>()
    array<label> zb = array.new<label>()
    array<int> zsec = array.new<int>()
    array<CisdBook> zbk = array.new<CisdBook>()
    array<int> zdi = array.new<int>()
    f_zoneRows(bookA, cisd_c2_a, cisd_ltf_a, cisd_en_a, zl, zd, zc, zx1, zx2, zn, zp, zb, zsec, zbk, zdi)
    f_zoneRows(bookB, cisd_c2_b, cisd_ltf_b, cisd_en_b, zl, zd, zc, zx1, zx2, zn, zp, zb, zsec, zbk, zdi)
    f_zoneRows(bookC, cisd_c2_c, cisd_ltf_c, cisd_en_c, zl, zd, zc, zx1, zx2, zn, zp, zb, zsec, zbk, zdi)
    if barstate.islast
        while zoneBoxes.size() > 0
            box.delete(zoneBoxes.pop())
        while zoneLbls.size() > 0
            label.delete(zoneLbls.pop())
    int zN = zl.size()
    if zN >= 2
        array<bool> zu = array.new<bool>(zN, false)
        for i = 0 to zN - 2
            if not zu.get(i)
                // gather this group's member row indices (same direction, same C2 close)
                array<int> mi = array.new<int>()
                mi.push(i)
                for j = i + 1 to zN - 1
                    if not zu.get(j) and zd.get(j) == zd.get(i) and zc.get(j) == zc.get(i)
                        zu.set(j, true)
                        mi.push(j)
                if mi.size() >= 2
                    // sort members by pair-TF seconds, lowest first (insertion, n <= 3)
                    for a = 1 to mi.size() - 1
                        int mv = mi.get(a)
                        int sb = a - 1
                        while sb >= 0 and zsec.get(mi.get(sb)) > zsec.get(mv)
                            mi.set(sb + 1, mi.get(sb))
                            sb -= 1
                        mi.set(sb + 1, mv)
                    // aggregates + hide the member tags in favor of one combined tag
                    float top = zl.get(mi.get(0))
                    float bot = zl.get(mi.get(0))
                    int gx1 = zx1.get(mi.get(0))
                    int gx2 = zx2.get(mi.get(0))
                    float zSwingLo = na
                    float zSwingHi = na
                    string gNames = ''
                    string gPairs = ''
                    for a = 0 to mi.size() - 1
                        int r = mi.get(a)
                        top := math.max(top, zl.get(r))
                        bot := math.min(bot, zl.get(r))
                        gx1 := math.min(gx1, zx1.get(r))
                        gx2 := math.max(gx2, zx2.get(r))
                        float mc2 = zbk.get(r).cisd_done_c2.get(zdi.get(r))
                        if not na(mc2)
                            zSwingLo := na(zSwingLo) ? mc2 : math.min(zSwingLo, mc2)
                            zSwingHi := na(zSwingHi) ? mc2 : math.max(zSwingHi, mc2)
                        gNames := gNames + (a > 0 ? '+' : '') + zn.get(r)
                        gPairs := gPairs + (a > 0 ? ' + ' : '') + zp.get(r)
                    color zCol = zd.get(i) == 1 ? in_cisd_bull : in_cisd_bear
                    string zKey = str.tostring(zd.get(i)) + '|' + str.tostring(zc.get(i)) + '|' + gNames
                    // BROKEN check: a bar CLOSING through the stack's SWING EXTREME kills the
                    // zone for good - bull: close below the lowest member C2 low; bear: close
                    // above the highest member C2 high. The CISD levels can be closed through
                    // and the zone lives; the swing going is what invalidates the structure.
                    // A dead key never re-forms; members return to the normal setup budget.
                    float zStopB = nz(zSwingLo, bot)
                    float zStopS = nz(zSwingHi, top)
                    bool zAlive = zoneDead.indexof(zKey) < 0
                    if zAlive and ((zd.get(i) == 1 and close < zStopB) or (zd.get(i) == -1 and close > zStopS))
                        zoneDead.push(zKey)
                        if zoneDead.size() > 20
                            zoneDead.shift()
                        zAlive := false
                    if zAlive
                        for a = 0 to mi.size() - 1
                            int r = mi.get(a)
                            label.set_textcolor(zb.get(r), COL_NONE)
                            CisdBook zrb = zbk.get(r)
                            if not na(zrb.cisd_done_stack) and zrb.cisd_done_stack.size() > zdi.get(r)
                                zrb.cisd_done_stack.set(zdi.get(r), true)
                    if zAlive and zoneSeen.indexof(zKey) < 0
                        zoneSeen.push(zKey)
                        if zoneSeen.size() > 20
                            zoneSeen.shift()
                        f_alert(al_stack, gPairs + (zd.get(i) == 1 ? ' Bull' : ' Bear') + ' STACKED - all pairs confirmed. Zone ' + str.tostring(bot, format.mintick) + ' - ' + str.tostring(top, format.mintick) + '.', true)
                    // SD ladder handoff
                    if zAlive and in_sd_show and array.size(sd_levels_arr) > 0
                        int sIdx = zoneStageKey.indexof(zKey)
                        int stage = sIdx >= 0 ? zoneStageVal.get(sIdx) : 0
                        if stage > mi.size() - 1
                            stage := mi.size() - 1
                        bool adv = true
                        while adv and stage < mi.size() - 1
                            adv := false
                            int r = mi.get(stage)
                            float finalLvl = f_sdFinalLvl(zbk.get(r), zdi.get(r))
                            if not na(finalLvl)
                                if (zd.get(r) == 1 and high >= finalLvl) or (zd.get(r) == -1 and low <= finalLvl)
                                    stage += 1
                                    adv := true
                        if sIdx >= 0
                            zoneStageVal.set(sIdx, stage)
                        else
                            zoneStageKey.push(zKey)
                            zoneStageVal.push(stage)
                            if zoneStageKey.size() > 20
                                zoneStageKey.shift()
                                zoneStageVal.shift()
                        for a = 0 to mi.size() - 1
                            if a != stage
                                int r = mi.get(a)
                                f_sdBlockColor(zbk.get(r), zdi.get(r), COL_NONE)
                    if zAlive and barstate.islast
                        int bx2 = in_zone_ext == 'Confirm' ? gx2 : time
                        zoneBoxes.push(box.new(gx1, top, bx2, bot, xloc = xloc.bar_time, bgcolor = color.new(zCol, 85), border_color = COL_NONE, extend = in_zone_ext == 'Extend right' ? extend.right : extend.none))
                        zoneLbls.push(label.new(bx2, math.avg(top, bot), 'CISD ' + gNames, xloc = xloc.bar_time, style = label.style_label_left, size = size.tiny, color = COL_NONE, textcolor = zCol))
// SMT on the HTF mini candles: C1/C2 divergence per enabled slot at its own TF.
// Guard: with a custom daily open, the 1D minis roll at the custom time while the SMT
// stream rolls at the true exchange boundary - the marks would land on mismatched candle
// pairs, so SMT is suppressed on a 1D slot whenever the custom daily open is active.
bool smtM1On = in_smt_on and in_s1_on and timeframe.in_seconds(in_s1_tf) > timeframe.in_seconds() and not (in_customd_on and in_s1_tf == '1D')
bool smtM2On = in_smt_on and in_s2_on and timeframe.in_seconds(in_s2_tf) > timeframe.in_seconds() and not (in_customd_on and in_s2_tf == '1D')
bool smtM3On = in_smt_on and in_s3_on and timeframe.in_seconds(in_s3_tf) > timeframe.in_seconds() and not (in_customd_on and in_s3_tf == '1D')
[s1c2b, s1c2bS, s1c2bW, s1c2s, s1c2sS, s1c2sW] = f_smtStream(in_s1_tf, f_tfName(in_s1_tf), smtM1On)
[s2c2b, s2c2bS, s2c2bW, s2c2s, s2c2sS, s2c2sW] = f_smtStream(in_s2_tf, f_tfName(in_s2_tf), smtM2On)
[s3c2b, s3c2bS, s3c2bW, s3c2s, s3c2sS, s3c2sW] = f_smtStream(in_s3_tf, f_tfName(in_s3_tf), smtM3On)
if smtM1On
    f_markSmt(slot1, s1c2b, s1c2bS, s1c2s, s1c2sS)
    if s1c2b
        f_alert(al_smt, 'SMT ' + f_tfName(in_s1_tf) + ' Bull - ' + (s1c2bW ? syminfo.ticker + ' swept the lows, ' + s1c2bS + ' held' : s1c2bS + ' swept the lows, ' + syminfo.ticker + ' held'), true)
    if s1c2s
        f_alert(al_smt, 'SMT ' + f_tfName(in_s1_tf) + ' Bear - ' + (s1c2sW ? syminfo.ticker + ' swept the highs, ' + s1c2sS + ' held' : s1c2sS + ' swept the highs, ' + syminfo.ticker + ' held'), true)
if smtM2On
    f_markSmt(slot2, s2c2b, s2c2bS, s2c2s, s2c2sS)
    if s2c2b
        f_alert(al_smt, 'SMT ' + f_tfName(in_s2_tf) + ' Bull - ' + (s2c2bW ? syminfo.ticker + ' swept the lows, ' + s2c2bS + ' held' : s2c2bS + ' swept the lows, ' + syminfo.ticker + ' held'), true)
    if s2c2s
        f_alert(al_smt, 'SMT ' + f_tfName(in_s2_tf) + ' Bear - ' + (s2c2sW ? syminfo.ticker + ' swept the highs, ' + s2c2sS + ' held' : s2c2sS + ' swept the highs, ' + syminfo.ticker + ' held'), true)
if smtM3On
    f_markSmt(slot3, s3c2b, s3c2bS, s3c2s, s3c2sS)
    if s3c2b
        f_alert(al_smt, 'SMT ' + f_tfName(in_s3_tf) + ' Bull - ' + (s3c2bW ? syminfo.ticker + ' swept the lows, ' + s3c2bS + ' held' : s3c2bS + ' swept the lows, ' + syminfo.ticker + ' held'), true)
    if s3c2s
        f_alert(al_smt, 'SMT ' + f_tfName(in_s3_tf) + ' Bear - ' + (s3c2sW ? syminfo.ticker + ' swept the highs, ' + s3c2sS + ' held' : s3c2sS + ' swept the highs, ' + syminfo.ticker + ' held'), true)

// Draw pass - last bar only. Everything mini-related is rebuilt from data here.
if barstate.islast
    float gT = na
    float gB = na
    if in_tag_align == 'Align'
        if use1 and slot1.h.size() > 0
            gT := na(gT) ? slot1.h.max() : math.max(gT, slot1.h.max())
            gB := na(gB) ? slot1.l.min() : math.min(gB, slot1.l.min())
        if use2 and slot2.h.size() > 0
            gT := na(gT) ? slot2.h.max() : math.max(gT, slot2.h.max())
            gB := na(gB) ? slot2.l.min() : math.min(gB, slot2.l.min())
        if use3 and slot3.h.size() > 0
            gT := na(gT) ? slot3.h.max() : math.max(gT, slot3.h.max())
            gB := na(gB) ? slot3.l.min() : math.min(gB, slot3.l.min())
    int trK = in_tr_anchor == 'First Timeframe' ? (use1 ? 1 : use2 ? 2 : use3 ? 3 : 0) : (use3 ? 3 : use2 ? 2 : use1 ? 1 : 0)
    int x = bar_index + in_pad
    int anchorX = x
    if use1
        if trK == 1
            anchorX := x
        x := f_render(slot1, in_s1_tf, x, gT, gB)
    if use2
        if trK == 2
            anchorX := x
        x := f_render(slot2, in_s2_tf, x, gT, gB)
    if use3
        if trK == 3
            anchorX := x
        x := f_render(slot3, in_s3_tf, x, gT, gB)
    if trK == 2
        f_trace(slot2, anchorX)
    else if trK == 3
        f_trace(slot3, anchorX)
    else
        f_trace(slot1, anchorX)


// - - - - - - - - -
// SD LEVEL HIT ALERT (one box). Scans every drawn SD projection line across all
// three books; the moment price WICKS through a level it fires the alert and latches
// that level so it never fires twice.
// v4.47: that scan runs LIVE now, not once per closed bar - so it also runs per tick.
// Which is affordable only because of v4.44 below: the latch test is one array .get(),
// where the old .indexof() walk through a 300-deep global would have made a per-tick
// scan genuinely expensive. The optimisation is what paid for the instant alert. v4.19: ladders are per-setup now, so a
// level's index inside its own ladder maps straight back into sd_levels_arr - no modulo
// arithmetic to get wrong.
// v4.44: the latch moved INTO the ladder (sd.hits, index-parallel with sd.lines). What it
// replaced was a global registry searched with .indexof() - up to 300 comparisons per
// level, per setup, per book, every confirmed bar, and it grew with the pool. Now it is
// one .get(). Two things fall out for free: the 300/100 drain disappears, and a culled
// setup can no longer leave a deleted line's reference sitting in a global to be read.
// The size guard is the house parity rule, not paranoia - if the two columns ever
// disagreed, the scan would read a latch belonging to a different level.
f_sdHitScan(CisdBook bk, string tag) =>
    int perSet = array.size(sd_levels_arr)
    // v4.47: barstate.isconfirmed removed from this gate. It has to go from BOTH places or
    // nothing changes: sd.hits latches the instant the wick lands, so a transport that waited
    // for the close would find the latch already set and the `if not sd.hits.get(i)` guard
    // would swallow the alert forever. The scan runs live; f_alert is told not to wait.
    if al_sd and perSet > 0 and not na(bk.cisd_done_sd) and bk.cisd_done_sd.size() > 0
        for s = 0 to bk.cisd_done_sd.size() - 1
            SdLadder sd = bk.cisd_done_sd.get(s)
            if not na(sd) and not na(sd.lines) and sd.lines.size() > 0 and not na(sd.hits) and sd.hits.size() == sd.lines.size()
                for i = 0 to sd.lines.size() - 1
                    if not sd.hits.get(i)
                        line ln = sd.lines.get(i)
                        if not na(ln)
                            float p = line.get_y1(ln)
                            if not na(p) and low <= p and high >= p
                                sd.hits.set(i, true)
                                float lvl = array.get(sd_levels_arr, i)
                                f_alert(al_sd, tag + ' SD ' + str.tostring(lvl) + ' tagged @ ' + str.tostring(p, format.mintick), false)

// v4.28: pair tag, not just the C2 TF. This was the only CISD alert labelled with one
// timeframe, which read as if it fired off the C2 TF - it does not. The scan runs at
// GLOBAL scope, so low/high are the CHART's, and hits are detected on whatever chart the
// alert was built on (1m, per the setup note). Every sibling alert says "4h-15m"; so does
// this one now.
f_sdHitScan(bookA, f_tfName(cisd_c2_a) + '-' + f_tfName(cisd_ltf_a))
f_sdHitScan(bookB, f_tfName(cisd_c2_b) + '-' + f_tfName(cisd_ltf_b))
f_sdHitScan(bookC, f_tfName(cisd_c2_c) + '-' + f_tfName(cisd_ltf_c))


// ═══════════════════════════════════════════════════════════════════════════════
//  CISD TABLE (v4.21) — a READOUT, deliberately decoupled from what draws.
//  Most people run a single CISD on the chart to keep it clean. That is a DRAWING
//  choice and it should not decide what you are allowed to know. This runs the same
//  engine read-only on its own two timeframes and draws nothing on price, so the
//  chart stays as sparse as you like while the table still shows the whole picture.
// ═══════════════════════════════════════════════════════════════════════════════
f_ctPos(simple string s) =>
    switch s
        'Top Left' => position.top_left
        'Bottom Right' => position.bottom_right
        'Bottom Left' => position.bottom_left
        'Middle Right' => position.middle_right
        => position.top_right

// v4.24: one row's state machine. Runs INSIDE request.security, on the CISD timeframe,
// so every arm / confirm / kill edge is seen on the LTF bar it actually happens. Sampling
// the engine's counters from the chart timeline instead would silently drop any edge that
// lands between two chart bars — the same reason the engine exports monotonic counters.
//
// The engine returns 30 values; this needs six. The rest are named and discarded rather
// than positionally skipped, so it stays readable and stays in step if the tuple changes.
//
// Returns [sequence, it is bullish, the CISD level, the CISD confirmed, the hunt died]:
//   sequence  0 = nothing live · 3 = inside C3 · 4 = inside C4
//
// The sequence is the whole lifetime of the row. A C2 seals and the row opens at C3
// (C2 has already closed by then — that IS the arming edge). One HTF candle later it
// steps to C4. One more and it is over: either that candle was itself a new C2 and the
// row restarts at C3, or nothing armed and the row blanks and waits.
f_ctState(simple string c2Tf) =>
    [tbA, tbLvl, tbST, tbHC, tbCC, tbCT, tsA, tsLvl, tsST, tsHC, tsCC, tsCT, tboO, tboS, tsoO, tsoS, tbc2L, tsc2H, tbc3s, tsc3s, _tbCC2, _tsCC2] = f_cisdEngine(c2Tf)   // v4.45: table path does not size, so the two new fields are named and dropped

    // Previous-bar snapshots held as `var` rather than read with [1]. Same result, but a
    // tuple-destructured local carrying history through a request.security expression is
    // exactly the kind of thing that is fine until it isn't — this cannot be ambiguous.
    var bool pbA  = false
    var bool psA  = false
    var int  pbHC = 0
    var int  psHC = 0
    var int  pbCC = 0
    var int  psCC = 0

    // huntCount / confCount are monotonic, so a change IS the event. A hunt whose active
    // flag drops WITHOUT its confCount moving is one that died on the C2 extreme — that
    // asymmetry is the only way to tell the two outcomes apart, and it is why the old
    // single-boolean read could never have distinguished them.
    bool bArm  = tbHC > pbHC
    bool sArm  = tsHC > psHC
    bool bConf = tbCC > pbCC
    bool sConf = tsCC > psCC
    bool bDead = pbA and not tbA and not bConf
    bool sDead = psA and not tsA and not sConf

    // HTF rollover on the C2 timeframe, detected exactly the way the engine detects it,
    // so the sequence step and the engine's own C2 evaluation always land on the same LTF
    // bar. Deriving the step from anything else would let them drift by a bar.
    bool newC2Bar = nz(ta.change(time(c2Tf)), 0) != 0

    var int   stSeq  = 0
    var bool  stBull = false
    var float stLvl  = na
    var bool  stConf = false
    var bool  stDead = false
    var bool  stFail = false

    // Ryan's law (v4.24): the row lives C3 → C4 and STOPS. If the candle after C4 is not
    // itself a new C2, the row resets and waits. A setup whose sequence has played out is
    // history, and history does not belong on a live readout — that is the whole reason
    // the old table was misleading in the other direction.
    if bArm or sArm
        // Both sides arming off the same C2 close can't happen under v4.27 either: the
        // indecisive outside candle is refused outright, and the decisive one satisfies
        // exactly one direction. Tiebreak stays as belt-and-braces: newer wins.
        bool takeBull = bArm and (not sArm or nz(tbST, 0) >= nz(tsST, 0))
        stSeq  := 3
        stBull := takeBull
        stLvl  := takeBull ? tbLvl : tsLvl
        stConf := false
        stDead := false
        stFail := false
    else if newC2Bar and stSeq > 0
        // C4 closing without a new C2 arming on that same edge ends the sequence. The arm
        // branch above is checked FIRST precisely so a C4 that turns out to be the next C2
        // restarts the row instead of blanking it.
        stSeq := stSeq >= 4 ? 0 : 4
        if stSeq == 0
            stLvl  := na
            stConf := false
            stDead := false
            stFail := false

    // Outcome flags colour the CISD cell; they never end the sequence on their own. Only
    // the side that OWNS the row can set them — otherwise a stale hunt still live on the
    // opposite side would stamp its result on somebody else's setup.
    if stSeq > 0
        if stBull ? bConf : sConf
            stConf := true
        if stBull ? bDead : sDead
            stDead := true

    // v4.25: the failure test, run here rather than inferred from the engine's flags.
    // Those flags cannot carry it — they clear at CONFIRMATION, so a setup that confirmed
    // and then had its C2 extreme taken never reached the table at all.
    //
    // This is deliberately the SAME test InvalidateConfirmed runs at ~1008: a raw trade
    // through the C2 extreme, wick counts (low/high, never close). Duplicated rather than
    // shared because that method walks drawn objects on the chart timeline and this runs
    // inside request.security on the CISD timeframe. IF EITHER SIDE EVER CHANGES, CHANGE
    // BOTH — a table that disagrees with what is drawn on price is worse than no table.
    //
    // Ryan's law (v4.25): a C3 breach FAILS the setup — that is the chart's red FC2 and it
    // means stand down. The identical breach in C4 does not fail anything; the setup has
    // simply run its course, so the column clears instead of crying wolf.
    float c2Ext = stBull ? tbc2L : tsc2H
    if stSeq > 0 and not na(c2Ext) and (stBull ? low < c2Ext : high > c2Ext)
        if stSeq == 3
            stFail := true
        else if not stFail
            // Already failed inside C3? Leave the FC2 standing — do not overwrite a real
            // failure with the quieter "it just ended".
            stSeq  := 0
            stLvl  := na
            stConf := false
            stDead := false

    pbA  := tbA
    psA  := tsA
    pbHC := tbHC
    psHC := tsHC
    pbCC := tbCC
    psCC := tsCC

    [stSeq, stBull, stLvl, stConf, stDead, stFail]

f_ctRead(simple string c2Tf, simple string ltfTf) =>
    [rSeq, rBull, rLvl, rConf, rDead, rFail] = request.security(syminfo.tickerid, ltfTf, f_ctState(c2Tf))
    [rSeq, rBull, rLvl, rConf, rDead, rFail]

// Resolve each column: the C2 TF picked, the CISD TF it pairs to, and whether the chart
// can even see that CISD timeframe (a CISD cannot be read from above its own TF).
string ct1C2 = in_ct_r1 == 'Off' ? '60' : f_ctTf(in_ct_r1)
string ct2C2 = in_ct_r2 == 'Off' ? '60' : f_ctTf(in_ct_r2)
string ct3C2 = in_ct_r3 == 'Off' ? '60' : f_ctTf(in_ct_r3)
string ct1Ltf = f_ltfForC2(ct1C2)
string ct2Ltf = f_ltfForC2(ct2C2)
string ct3Ltf = f_ltfForC2(ct3C2)
bool ct1On = in_ct_on and in_ct_r1 != 'Off'
bool ct2On = in_ct_on and in_ct_r2 != 'Off'
bool ct3On = in_ct_on and in_ct_r3 != 'Off'
bool ct1Ok = timeframe.in_seconds(timeframe.period) <= timeframe.in_seconds(ct1Ltf)
bool ct2Ok = timeframe.in_seconds(timeframe.period) <= timeframe.in_seconds(ct2Ltf)
bool ct3Ok = timeframe.in_seconds(timeframe.period) <= timeframe.in_seconds(ct3Ltf)

int ct1Seq = 0
bool ct1Bull = false
float ct1Lvl = na
bool ct1Conf = false
bool ct1Dead = false
bool ct1Fail = false
if ct1On and ct1Ok
    [q1, b1, l1, c1, d1, f1] = f_ctRead(ct1C2, ct1Ltf)
    ct1Seq := q1
    ct1Bull := b1
    ct1Lvl := l1
    ct1Conf := c1
    ct1Dead := d1
    ct1Fail := f1

int ct2Seq = 0
bool ct2Bull = false
float ct2Lvl = na
bool ct2Conf = false
bool ct2Dead = false
bool ct2Fail = false
if ct2On and ct2Ok
    [q2, b2, l2, c2, d2, f2] = f_ctRead(ct2C2, ct2Ltf)
    ct2Seq := q2
    ct2Bull := b2
    ct2Lvl := l2
    ct2Conf := c2
    ct2Dead := d2
    ct2Fail := f2

int ct3Seq = 0
bool ct3Bull = false
float ct3Lvl = na
bool ct3Conf = false
bool ct3Dead = false
bool ct3Fail = false
if ct3On and ct3Ok
    [q3, b3, l3, c3, d3, f3] = f_ctRead(ct3C2, ct3Ltf)
    ct3Seq := q3
    ct3Bull := b3
    ct3Lvl := l3
    ct3Conf := c3
    ct3Dead := d3
    ct3Fail := f3

// v4.24: TRANSPOSED — timeframes run ACROSS the top, fields down the side. The old
// one-row-per-timeframe layout meant a third timeframe made the table taller while the
// fields stayed cramped; this way each timeframe you add is one narrow column and every
// field gets a full row to itself. 4 columns (labels + up to 3 TFs) x 5 rows.
// v4.54: DAILY BIAS. Yesterday ([1]) against the day before ([2]). Close beyond a side = continuation
// that way; took a side and closed back inside = rejection, bias flips to the other side. Took both
// and closed inside, or took neither = Neutral. Idiom A throughout (closed daily bars only), so the
// row cannot change during the day.
bool   dbTookH = not na(dbH2) and lqPdhR > dbH2
bool   dbTookL = not na(dbL2) and lqPdlR < dbL2
int    dbDir = 0
string dbWhy = 'Inside day - took neither PDH nor PDL'
if not na(dbC1) and not na(dbH2) and not na(dbL2)
    if dbC1 > dbH2
        dbDir := 1
        dbWhy := 'Closed above PDH ' + str.tostring(dbH2, format.mintick)
    else if dbC1 < dbL2
        dbDir := -1
        dbWhy := 'Closed below PDL ' + str.tostring(dbL2, format.mintick)
    else if dbTookH and dbTookL
        dbWhy := 'Took both PDH and PDL, closed inside'
    else if dbTookL
        dbDir := 1
        dbWhy := 'Swept PDL ' + str.tostring(dbL2, format.mintick) + ', closed back inside'
    else if dbTookH
        dbDir := -1
        dbWhy := 'Swept PDH ' + str.tostring(dbH2, format.mintick) + ', closed back inside'
int ctRb = in_ct_bias ? 1 : 0   // row offset: every field row shifts down one when the bias row is on

var table ctbl = table.new(position.top_right, 4, 7, border_width = 1, frame_width = 1)

// Column renderer. Colours come straight off the inputs rather than being passed in, so
// changing one in Settings repaints on the next tick with no plumbing.
f_ctCol(int c, bool ok, simple string c2Tf, simple string ltfTf, int seq, bool bull, float lvl, bool conf, bool dead, bool fail) =>
    bool has = ok and seq > 0
    bool hasLvl = has and not na(lvl)
    color dirCol = bull ? in_ct_bull : in_ct_bear
    // v4.26: the two failures are DIFFERENT events and get different words, because only
    // one of them has a red tag on price.
    //   FC2  — it confirmed, you could have been in it, then it invalidated. Chart has the
    //          red FC2 tag. Same word, same colour, same meaning.
    //   Fail — it died while still hunting. Never gave an entry. Chart has the muted x.
    // Keeping these apart is what makes the rule "whatever the table says, price shows the
    // matching mark" actually true.
    // `dead` is the ENGINE's own "this hunt is gone" signal and `fail` is the table's own
    // breach test. They agree in every normal case; either one alone is enough to call the
    // setup over, so they are OR'd rather than one trusted over the other.
    bool failed = fail or dead
    string stTxt = not ok ? 'chart TF' : not has ? 'No' : failed ? (conf ? 'FC2' : 'Fail') : 'Yes'
    color stCol = not has ? in_ct_mute : failed ? (conf ? in_ct_bear : in_ct_mute) : dirCol
    // v4.26: confirmation is SPELLED OUT rather than carried by the level's colour. A C2
    // can be sealed while its CISD has not been closed through yet, and nobody reads that
    // off a shade of green on their first day with the tool.
    color offCol = has and not failed ? in_ct_txt : in_ct_mute
    table.cell(ctbl, c, ctRb + 0, f_tfName(c2Tf), bgcolor = in_ct_bg, text_color = in_ct_hdrCol, text_size = in_ct_size, tooltip = f_tfName(c2Tf) + ' C2  →  reading the ' + f_tfName(ltfTf) + ' CISD')
    table.cell(ctbl, c, ctRb + 1, stTxt, bgcolor = in_ct_bg, text_color = stCol, text_size = in_ct_size)
    table.cell(ctbl, c, ctRb + 2, has ? (bull ? 'Bull' : 'Bear') : '—', bgcolor = in_ct_bg, text_color = has and not failed ? dirCol : in_ct_mute, text_size = in_ct_size)
    table.cell(ctbl, c, ctRb + 3, hasLvl ? str.tostring(lvl, format.mintick) : '—', bgcolor = in_ct_bg, text_color = offCol, text_size = in_ct_size)
    table.cell(ctbl, c, ctRb + 4, not has ? '—' : conf ? 'Confirmed' : 'Unconfirmed', bgcolor = in_ct_bg, text_color = not has ? in_ct_mute : failed ? in_ct_mute : conf ? dirCol : in_ct_txt, text_size = in_ct_size)
    table.cell(ctbl, c, ctRb + 5, has ? 'C' + str.tostring(seq) : '—', bgcolor = in_ct_bg, text_color = offCol, text_size = in_ct_size)
    0

if in_ct_on and barstate.islast
    // v4.23: position and frame colours are SET here, not baked into table.new. The
    // table is `var`, so anything passed to the constructor is fixed on the first bar
    // and would silently ignore every settings change after it.
    table.set_position(ctbl, f_ctPos(in_ct_pos))
    table.set_frame_color(ctbl, in_ct_frame)
    table.set_border_color(ctbl, in_ct_border)
    table.clear(ctbl, 0, 0, 3, 6)
    // Field labels occupy column 0 when they are on; without them the timeframes start
    // at column 0 instead, so the table has no dead first column.
    int cIdx = in_ct_hdrOn ? 1 : 0
    if in_ct_hdrOn
        table.cell(ctbl, 0, ctRb + 0, '', bgcolor = in_ct_bg, text_size = in_ct_size, tooltip = 'The C2 timeframe — the higher-timeframe candle that has to seal before anything gets hunted.\n\nThe CISD each column reads is paired automatically off the pair table, so a 1H column is reading the 5m CISD. Hover a timeframe to see its pairing.')
        table.cell(ctbl, 0, ctRb + 1, 'C2 conf', bgcolor = in_ct_bg, text_color = in_ct_hdrCol, text_size = in_ct_size, tooltip = 'Has a C2 sealed on this timeframe, and is its sequence still running.\n\nYes — a C2 closed as a sweep-and-close-back, and the sequence below is live.\nFC2 — the CISD had CONFIRMED and then price pushed back through the C2\'s own extreme inside C3. You could have been in this one. Stand down. Price carries the matching red FC2 tag.\nFail — the same push through the C2 extreme, but it happened while the CISD was still Unconfirmed, so there was never an entry. Price carries a muted × at the extreme instead (visible when Potential CISD is on).\nNo — nothing running. Either no sweep, or the last sequence already played out.\nchart TF — your chart sits above the CISD timeframe this column reads, so there is no data. Drop the chart timeframe and it fills in.\n\nEither failure counts ANY push through the extreme, wick or close — it does not wait for the C3 to close. And the SAME push during C4 is not a failure at all: that setup has simply run its course, so the column clears quietly instead of crying wolf.')
        table.cell(ctbl, 0, ctRb + 2, 'Dir', bgcolor = in_ct_bg, text_color = in_ct_hdrCol, text_size = in_ct_size, tooltip = 'Direction of the setup.\n\nBull — the C2 swept the prior candle\'s LOW and closed back above it.\nBear — swept the prior HIGH and closed back below it.\n\nIf both sides somehow arm at once, the column shows whichever armed most recently.')
        table.cell(ctbl, 0, ctRb + 3, 'CISD', bgcolor = in_ct_bg, text_color = in_ct_hdrCol, text_size = in_ct_size, tooltip = 'The CISD price on the paired lower timeframe — the level price has to CLOSE through for the setup to confirm. Shown whether or not that CISD is drawn on your chart, and it is the number worth putting an alert on.\n\nThe colour is the outcome:\nNormal — still being hunted, price has not closed through it yet.\nBull/Bear coloured — CONFIRMED, price closed through.\nMuted — the hunt was killed: price took the C2\'s own extreme before the level could confirm. The sequence keeps counting so you can still see the timeframe had a setup and it failed.')
        table.cell(ctbl, 0, ctRb + 4, 'State', bgcolor = in_ct_bg, text_color = in_ct_hdrCol, text_size = in_ct_size, tooltip = 'Whether the CISD directly above has actually been CLOSED THROUGH yet. This is not the same question as the C2.\n\nUnconfirmed — the C2 has sealed and the level is set, but price has not closed through it. There is no entry trigger yet. On the chart this is the dashed Potential CISD line, if you have that switched on.\n\nConfirmed — price CLOSED through the level. This is the moment the setup becomes real: the line goes solid, its tag appears, and the std-dev ladder is built.\n\nA C2 can sit Unconfirmed for its whole sequence and never trigger. That is normal and it is most of them.')
        table.cell(ctbl, 0, ctRb + 5, 'Seq', bgcolor = in_ct_bg, text_color = in_ct_hdrCol, text_size = in_ct_size, tooltip = 'Which candle of the sequence this timeframe is currently inside.\n\nC3 — the candle straight after the C2 sealed. This is where delivery normally happens.\nC4 — one candle later.\n\nIt STOPS at C4. If the candle after C4 is not itself a new C2, the whole column blanks and waits for the next one. A sequence that has already played out is history, and history does not belong on a live readout.\n\nThere is no C2 shown here because by the time a column populates, the C2 has already closed — that close is what opens C3.')
    if ct1On
        f_ctCol(cIdx, ct1Ok, ct1C2, ct1Ltf, ct1Seq, ct1Bull, ct1Lvl, ct1Conf, ct1Dead, ct1Fail)
        cIdx := cIdx + 1
    if ct2On
        f_ctCol(cIdx, ct2Ok, ct2C2, ct2Ltf, ct2Seq, ct2Bull, ct2Lvl, ct2Conf, ct2Dead, ct2Fail)
        cIdx := cIdx + 1
    if ct3On
        f_ctCol(cIdx, ct3Ok, ct3C2, ct3Ltf, ct3Seq, ct3Bull, ct3Lvl, ct3Conf, ct3Dead, ct3Fail)
        cIdx := cIdx + 1
    // v4.54: Daily Bias row. Label in column 0 when field labels are on, value next to it, and the
    // rest of the row filled blank so the grid stays square.
    if in_ct_bias
        int lastC = math.max(cIdx - 1, in_ct_hdrOn ? 1 : 0)
        int vC = in_ct_hdrOn ? 1 : 0
        if in_ct_hdrOn
            table.cell(ctbl, 0, 0, 'Daily Bias', bgcolor = in_ct_bg, text_color = in_ct_hdrCol, text_size = in_ct_size, tooltip = 'Yesterday\'s close against the day before\'s PDH and PDL. Hover the value for the rule that fired.')
        table.cell(ctbl, vC, 0, dbDir == 1 ? 'Bullish' : dbDir == -1 ? 'Bearish' : 'Neutral', bgcolor = in_ct_bg, text_color = dbDir == 1 ? in_ct_bull : dbDir == -1 ? in_ct_bear : in_ct_mute, text_size = in_ct_size, tooltip = dbWhy)
        if lastC > vC
            for cc = vC + 1 to lastC
                table.cell(ctbl, cc, 0, '', bgcolor = in_ct_bg, text_size = in_ct_size)
